flux branch T-1890/time-of-use-pricing commits 5 (+ review fixes) files 146 touched lines +13778 / -2794 findings 13 fixed tests Go + 364 FluxCore green

Pre-push review: T-1890/time-of-use-pricing

Reworks electricity pricing from three flat rates into daily time bands, adds same-day plan succession, and makes the plan the single source of truth for the free window — across a Go backend (poller, Lambda, three CLIs) and the Swift app. Five commits, 146 files.

Four parallel review agents covered code reuse, code quality, efficiency, and spec/docs/test adherence. Two of them independently found the same DST boundary bug. All findings below were verified against the source before acting.

At a glance

  • Fixed a live DST bug. offpeakWindow.bounds resolved the free window as midnight-plus-elapsed-minutes while liveBandImports in the same file used wall-clock plan.SegmentBounds. On Sydney's two transition days the two sat an hour apart inside a single response, so today's peakGridImportKwh would not have equalled the sum of bandImports. Two agents found this independently; the design document explicitly forbids the arithmetic that was used.
  • Fixed a silent half-migration. cmd/migrate-pricing warned-and-skipped on an undecodable row. That row would stay untransformed and drop every day it prices out of the golden check, so --apply would report success and exit 0 having half-migrated the table — the exact outcome the tool exists to prevent.
  • Closed a validation gap. replace-open-ended validated only the successor, never the projected closing row, so a successor starting on or before the closing plan's own start date produced a zero-day or inverted plan that every other write path rejects.
  • Closed a latent hole: a free band ending at 24:00 passed plan validation but was rejected by the downstream HH:MM parsers, silently dropping blocks and peak-periods into whole-day-rated mode on a day that does have a free window. Reachable from the editor via Q39's 23:59→24:00 mapping.
  • Stopped an unbounded hourly waste. On a date no plan prices, the summarisation pass re-queried a full day of readings (~8,640 rows) every hour forever to compute nothing and write nothing — a regression, since the pre-feature code returned before that query.
  • Added the DST regression test that was missing. The existing DST coverage asserted the UTC offset, which is correct either way; the new test asserts the wall-clock hour and that the window bounds equal the adjacent rated-segment bounds. Verified it fails against the original code by 3600s in both directions.
  • Six documents were stale, including CLAUDE.md (still naming SSM as the window's source) and prerequisites.md, which told the operator to run a --dry-run flag that does not exist — on the production cutover step.

Verdict

Ready to push

The implementation matches the spec closely, and the parts that carry the most risk — the migration's golden check, the three-tier cost resolution, the cross-language vectors — are the parts built most carefully. Review found and fixed two genuine correctness bugs, one silent-failure hazard in the migration tool, a validation gap, and a permanent hourly waste in the poller, plus six stale documents.

Everything is verified: make fmt/vet/lint/test and make ios-lint pass, and all 364 FluxCore tests pass. The one red iOS app test (refreshSkipsWhenAlreadyLoading) is a pre-existing timing flake in Dashboard code this branch does not touch; it passes in isolation.

Task 39 is intentionally still open — removing the transitional legacy read transform is gated on the production migration, which cannot run until this is merged and deployed.

Review findings

16 raised · 14 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Flux tracks a home battery and shows what the electricity costs. Until now it assumed one price for power all day, plus one free window in the middle (11am–2pm) when the battery charges for nothing.

A new electricity plan is arriving that doesn't work that way. It has three prices depending on the time of day: free from 10am–3pm, cheap from 1am–6am, and normal the rest of the time. The old model couldn't describe that at all.

So a plan is now a set of time bands. You enter a default price plus the exceptions — “free 10:00–15:00”, “$0.28 from 01:00–06:00” — and everything else costs the default. The system works out the full 24-hour picture from that.

Two things changed alongside it:

  • Plans hand over to each other on a date. You can enter the new plan today with a start date next month; on that date it takes over automatically.
  • The free window now comes from the plan. It used to be a separate AWS setting somebody had to remember to change. Now when the plan switches, the window switches with it.

Why It Matters

Without this, on the day the new plan starts someone would have to hand-edit an AWS setting at exactly the right moment, and every cost shown would be wrong — the cheap 1am–6am power would be priced at the full rate.

There's also a promise being kept: every cost the app has ever shown for a past day must still show the same number afterwards. A migration tool proves that by calculating every historical day's cost both the old way and the new way, and refusing to change anything if a single day disagrees.

Key Concepts

  • Band — a slice of the day with one price. Bands sit end-to-end covering all 24 hours, no gaps, no overlaps.
  • Free window — the band that costs nothing, when the battery deliberately charges. At most one per plan.
  • kWh — a unit of energy. A 1000-watt heater running for an hour uses 1 kWh.
  • Integration — the system takes a power reading every 10 seconds and adds them up over a time range to work out the energy used in that range. That's how it knows how much power was drawn during each band.
  • Exclusive end date — a plan that ends on 1 August does not price 1 August; its successor does. Both rows carry the same date, so nothing has to add or subtract a day.

What This Review Found

The biggest issue was about daylight saving. Twice a year a day is 23 or 25 hours long. One part of the code worked out “11am” by counting eleven hours forward from midnight — which lands on noon on the day the clocks jump. The code right next to it did it correctly. So on those two days the app would have reported two different answers for the same thing in the same response. It's fixed, with a test that fails if anyone reintroduces it.

Changes Overview

Five commits in dependency order: a new internal/plan leaf package (band model, validation, segmentation, per-date plan selection, DST-correct bounds, three-tier costing); the Lambda API moving to the band shape and plan-derived windows; the poller gaining PlanSource, a midnight-anchored scheduler and per-band capture, plus updated backfill CLIs and a new cmd/migrate-pricing; and the app replacing PricingPeriod with PricingPlan throughout.

Implementation Approach

Storage is “what the user entered”, not what's derived. A plan stores defaultRate + windows; the contiguous full-day band list comes from plan.Segments on demand. This makes gaps and partial coverage unrepresentable — uncovered time carries the default rate — so two of the four validation rules the requirements ask for hold by construction, and the editor round-trips exactly what was typed.

Costs resolve in three tiers: the stored per-band split when its geometry matches the plan and the free band's import is resolvable; otherwise the pre-band single-rate formula, applicable whenever the plan's rated segments share one rate; otherwise all import at the highest rate with no savings. Tier 2 is load-bearing — it's why historical costs are unchanged with no data backfill. Readings older than 30 days are gone, so a backfill couldn't reach most history.

One physical quantity, one writer. The flux-offpeak row exclusively owns free-window import; bandImports stores rated segments only. Peak grid import and the band split come from a single integration so they cannot disagree — and the review extended that principle to /day and /history, which now share one helper for the today-vs-stored decision.

The two languages are pinned by shared vectors. Segmentation and cost resolution exist in both Go and Swift; pricing_segments.json and pricing_costs.json are consumed by tests on both sides, so a divergence fails a test rather than showing two numbers on two screens.

Trade-offs

  • Exclusive end dates over inclusive-plus-UI-translation: every consumer would otherwise carry ±1 arithmetic; now exactly one place does.
  • One-time migration, no compatibility layer. For a two-user app with a handful of rows, permanent dual code paths buy nothing. Legacy builds fail safely (no costs shown) in the interim.
  • Uncached per-request pricing reads. A Scan of a handful of rows inside the existing errgroup is negligible next to the four queries already there. Verified: one fetch per request, genuinely parallel.
  • Segmentation duplicated across languages — accepted, because server-only costing would mean the app couldn't price a cached day offline.

What This Review Changed

Two correctness fixes (the DST boundary; a succession path accepting zero-day plans), one silent-failure fix (the migration tool's warn-and-continue on undecodable rows), one latent-hole fix (a free band ending at 24:00), one efficiency fix (the poller re-querying a full day of readings hourly forever on unpriced dates), several deduplications, and six stale documents. Plus the DST regression test that was missing — verified to fail against the original code before the fix was restored.

Technical Deep Dive

DST is the sharp edge. Band boundaries must resolve on the day's wall clock, not as elapsed minutes from midnight. plan.SegmentBounds uses time.Date in the location so adjacent segments share a boundary instant and per-segment integrals over a 23- or 25-hour day still sum to the whole-day integral; a rapid property test asserts exactly that.

Pre-review, internal/api/compute.go's offpeakWindow.bounds still used dayStart.Add(elapsed) while liveBandImports in the same file used SegmentBounds. On 2026-10-04 the free-window edge and the band edges beside it sat an hour apart inside one response, so today's peakGridImportKwh would not have equalled the sum of bandImports. The pre-existing DST test asserted the UTC offset (+11), which is correct whichever way the boundary resolves — a proxy assertion that correlated with the property but did not pin it.

Failure semantics are decomposed per outcome. The summarisation pass replaced a single early return with typed gating: a plan read failure writes nothing and is retried; a plan with a free band runs everything; a plan without one runs window-independent blocks in whole-day-rated mode; a day no plan prices gets socLow with the band sentinel left unset so a backfill can still repair it. The invariants: a semantic absence never returns before window-independent work runs, and a read failure never sets sentinels. This review added a fifth outcome — once those stats exist on an unpriced date there is nothing left to compute, so the pass now skips before the readings query rather than re-reading ~8,640 rows hourly in perpetuity.

Geometry is snapshotted, not assumed. Plan windows stay editable after a plan has priced days (Q16), so a later edit must be detectable rather than silently repricing history. The cost join compares stored geometry against the plan's current segmentation and degrades to a lower tier on mismatch. A sparse-complete off-peak row (integratedAt set, zero samples) is a zero-delta artifact, not a measured zero, and counts as unusable.

The migration verifies against an independent implementation. golden.go re-implements the legacy three-rate formula rather than calling the shared helper — a check that reuses the code under test proves nothing. Its row decoding was hardened here: warn-and-continue on an undecodable row would leave that row untransformed and drop every day it prices out of the golden check, so --apply would exit 0 having half-migrated the table.

Architecture Impact

  • The poller gains a flux-pricing dependency it never had. Read-only IAM; the Lambda keeps sole write access. PlanSource treats read failures as transient and serves last-good — never “no plan”, which would silently strip a day of its free window and its band split.
  • internal/plan is a genuine leaf (only fmt, math, sort, time), consumed by api, dynamo, poller and three CLIs.
  • /status.offpeak became nullable and the widget default constants were deleted, so no client can substitute the legacy window on a no-free-band day.
  • Two end-date semantics coexist until migration runs — bounded by cutover ordering, detectable via peakRate, and replace-open-ended refuses to run against a legacy closing row rather than producing a double-shifted date.

Potential Issues

  • Cutover is ordered and manual. Deploy → migrate → enter the new plan → switch date. Task 39 is correctly gated on the migration. The runbook named a non-existent --dry-run flag; fixed.
  • Window edits degrade multi-rate days to the fallback tier. Energy is frozen at capture, rates apply at display — so a rate edit reprices history cleanly, but a window edit invalidates the geometry join until a backfill re-captures, which is only possible within the 30-day readings TTL.
  • Unpriced days lose dailyUsage/peakPeriods permanently. The band sentinel stays unset so the split is repairable, but the derived sentinel is set with those fields absent and nothing recomputes the five-block panel. Matches the design table; worth confirming it is intended.
  • Today's rated region is still integrated twice per request. Not a wrong number now that the boundaries agree, but ~1.2 MB of scratch per request and two different usability gates. Left as a follow-up.
  • AC 6.4 has no cross-language pin. The Swift draft validator mirrors plan.Validate by hand; the other two cross-language contracts have shared vectors and this one does not.

Important changes — detailed

internal/plan: the band domain as a leaf package

internal/plan/plan.go

Why it matters. Everything else depends on this. It imports nothing from other Flux packages, so the Lambda, poller, three CLIs and the migration tool can all share one definition of what a plan is and what makes one valid.

What to look at. internal/plan/plan.go, segments.go, costs.go

Takeaway. Storing what the user entered (default rate + exception windows) rather than the derived segmentation makes two whole classes of validation error unrepresentable: uncovered time simply carries the default rate, so gaps and partial coverage cannot exist. Deriving the canonical form on demand is cheaper than validating that a stored canonical form is still canonical.
Rationale. Decision 4. It also round-trips the editor exactly — there is no ambiguity about which stored segment was 'the default' when reopening a plan.

Exclusive end dates make succession a single literal date

internal/dynamo/pricing_transactional.go

Why it matters. Changes the meaning of every stored endDate, and the migration has to shift legacy values by a day to match. Gets the switch-day semantics right with no arithmetic anywhere else.

What to look at. ReplaceOpenEnded writes the same date to both rows; Covers is startDate <= d < endDate

Takeaway. When two representations differ by an off-by-one, put the translation in exactly one place — here, the migration — rather than distributing ±1 across the overlap check, the succession call, the Swift covers(), and the remediation copy. The half-open interval also makes overlap detection standard interval intersection.
Rationale. Decision 5 and Q4: 'old plan ends Aug 1, successor starts Aug 1' is stored literally, which is how the ticket phrased it and how the user thinks about it.

Three-tier cost resolution keeps every historical day identical

internal/plan/costs.go

Why it matters. AC 5.2 requires historical costs to be unchanged after migration. This is how that holds with zero backfill of daily-energy rows.

What to look at. internal/plan/costs.go:97 DayCosts, mirrored by DayCosts.resolve in FluxCore

Takeaway. Tier 2 is the pre-band formula reproduced verbatim — including its server-peak preference and zero clamp — because the stored peakGridImportKwh differs from the eInput−offpeak residual by ~1.5% by design. Simplifying it to 'three multiplications' would have changed essentially every historical day and made the migration's golden check vacuous. When a check exists to prove equivalence, the thing being checked has to be the real formula.
Rationale. Decision 6 and Q30. Readings older than 30 days are gone, so a backfill could not reach most history; tier 2 prices those days exactly from data that already exists.

Wall-clock segment bounds, and the one caller that did not use them

internal/api/compute.go

Why it matters. The highest-severity finding in this review. Two review agents found it independently, and it would have produced two different numbers for the same physical quantity inside a single API response.

What to look at. internal/api/compute.go:68 bounds; internal/plan/segments.go:157 SegmentBounds; internal/api/dst_window_test.go

Takeaway. A helper written specifically to stop a bug class does not stop it in code that does not call it. plan.SegmentBounds, dynamo.IntegrateRatedBands and the design document all warned against midnight-plus-elapsed-minutes; one function in internal/api kept doing it anyway. The test that should have caught it asserted the UTC offset, which is right either way — assert the property you actually care about (the wall-clock hour), not a proxy that correlates with it.
Rationale. AC 3.8 requires band membership to follow local wall-clock time. Fixed during this review; the accompanying test was verified to fail against the original code before the fix was restored.

Per-outcome gating replaces a single early return

internal/poller/dailysummary.go

Why it matters. Determines what a day permanently does and does not get. Getting the failure taxonomy wrong here silently loses data with no error anywhere.

What to look at. internal/poller/dailysummary.go:60-160, the four-outcome table

Takeaway. 'No window' was three different situations wearing one coat: a transient read failure, a plan with no free band, and no plan at all. Collapsing them into one early return meant an infra blip could permanently mark a day done, and a no-window day starved window-independent work forever. The invariants worth stating explicitly: a semantic absence never returns before window-independent work runs, and a read failure never sets sentinels.
Rationale. Q33 and Q14. A fifth outcome was added in this review — once the window-independent stats exist for an unpriced date, skip before the readings query rather than re-reading the day hourly to compute nothing.

Migration verifies itself against an independently written formula

cmd/migrate-pricing/golden.go

Why it matters. This is the only thing standing between the cutover and silently repricing history. It runs against production data once.

What to look at. cmd/migrate-pricing/golden.go:73 legacyDayCosts; main.go:167 runMigration

Takeaway. The golden side deliberately re-implements the legacy three-rate formula instead of calling the shared helper: a check that reuses the code under test proves nothing. This is the rare case where duplication is the point.
Rationale. Q22 and the package doc. Its row decoding was hardened during this review — a warn-and-continue on an undecodable row would have produced a half-migrated table that still exited 0.

Geometry snapshots make stale splits detectable rather than silent

internal/dynamo/models.go

Why it matters. Plan windows stay editable after a plan has priced days, so stored splits can become stale. This is what stops that from silently mispricing.

What to look at. bandImports entries carry {start,end}; OffpeakItem carries windowStart/windowEnd; the join compares them

Takeaway. When stored derived data depends on a config that can change, store the config it was derived under alongside it. The join then degrades to a lower tier on mismatch instead of quietly combining a new window with old numbers. The same idea covers the sparse-complete row: integratedAt set with zero samples is a zero-delta artifact, not a measured zero, and the provenance is what makes the two distinguishable.
Rationale. Q23 and Q16 together: band boundaries had to stay editable (a day-one typo must be fixable), so the cost of that decision is paid by making staleness detectable.

Key decisions

The free window moves from SSM into the plan.

Keeping SSM authoritative would mean two definitions of the window that must be kept in sync precisely when they diverge — the switch date. It would also misattribute the window for historical days after any change. The cost is that the poller gains a dependency on flux-pricing, a table it never previously touched.

One-time migration, no compatibility layer.

For a two-user app with a handful of pricing rows that convert losslessly, supporting both shapes would mean permanent dual code paths in validation, cost math, and UI. Legacy app builds fail safely against a migrated API — PricingService publishes no periods and the cost cards hide via the existing nil-costs path.

The off-peak row exclusively owns free-window import.

bandImports stores rated segments only. One writer per physical quantity: a second capture of the same kWh could be desynchronised by a backfill repairing one and not the other. The same reasoning drives peak import and the band split coming from a single integration in dynamo.IntegrateRatedBands.

replace-open-ended rejects a legacy closing row rather than rewriting it.

Its closing write is a partial UpdateItem where every other path is a full-item Put. Against a not-yet-migrated row that would produce a row legacy-detected as inclusive while carrying an exclusive end date — which the read transform and the migration would then each shift by a day. A rewrite-in-transaction needs predecessor state the call does not carry and could clobber a concurrent edit; the cutover order already sequences migration first.

The Lambda reads the pricing table on every request, uncached.

Verified during review: listPlans is called exactly once per request on all three read endpoints, inside the pre-existing errgroup, genuinely parallel with the readings/energy/off-peak queries. It is a Scan, but of a handful of rows and off the critical path.

Segmentation and cost resolution are duplicated across Go and Swift on purpose.

Pinned to each other by internal/api/testdata/pricing_segments.json and pricing_costs.json, consumed by tests on both sides. The alternative — server-only costing — would mean the app could not price a cached day offline, which Q37 shows would break the Data Consistency rule the moment a fetch fails.

A new band-time parser rather than reusing ParseOffpeakWindow.

derivedstats.ParseOffpeakWindow rejected h > 23, and every plan's last segment ends at 24:00 — reusing it would have rejected every plan. This review extended that parser to accept 24:00 anyway, because the free window is still handed to it and a free band running to midnight was silently degrading the blocks and peak-periods layouts.

PassResultSkippedNoPlan added as a distinct metric dimension.

Introduced by this review alongside the unpriced-date gate. Reusing skipped-already-populated would have been semantically wrong — the day is not done, it is unpriceable — and a distinct dimension means an operator watching the metric can see dates going unpriced rather than inferring it from an absence.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorinternal/api/compute.go:68 — DST window boundsoffpeakWindow.bounds resolved the free window as startOfDaySydney(local).Add(minutes) while liveBandImports in the same file used wall-clock plan.SegmentBounds. On Sydney's two DST-transition days the two disagree by exactly one hour, so within a single /day or /history response today's peakGridImportKwh (day minus 12:00-15:00) would not equal the sum of bandImports (day minus 11:00-14:00). design.md explicitly forbids this arithmetic; SegmentBounds' own doc comment says it exists so no caller has to remember it. Found independently by two review agents.bounds now materialises the free segment and delegates to plan.SegmentBounds, matching the poller's capture and the backfill CLI.
majorinternal/api — no DST test on the live window pathinternal/plan, internal/poller, internal/dynamo and cmd/backfill-grid all have wall-clock/DST coverage; internal/api had none for bounds/liveOffpeakDeltas/livePeakGridImport. The existing DST test asserted the UTC offset (+11), which is correct whichever way the boundary resolves — which is exactly why the bug above shipped in three commits unnoticed.Added internal/api/dst_window_test.go: one test pins the wall-clock hour on both transition days, a second pins that the window bounds equal the adjacent rated-segment bounds instant-for-instant. Verified the pair fails against the original implementation by 3600s in both directions before restoring the fix.
majorcmd/migrate-pricing/main.go:397 — silent half-migrationpartitionRows logged a warning and continued past any row it could not unmarshal. A skipped legacy row is never transformed AND is excluded from the plan set, so every day it prices falls through checkDay into DaysUnpriced rather than being compared. The golden check therefore still passes, --apply still writes the other rows, and the process exits 0 having left a half-migrated table — contradicting the tool's own contract ("refuses to be the thing that silently reprices history") and diverging from dynamo.ListPricingRows, which errors on the same failure.partitionRows now returns an error and runMigration aborts before any write.
majorinternal/api/pricing_handler.go — replace-open-ended accepts zero-day plansrunPricingValidationChain validates only the successor payload plus cross-plan overlap; the projected closing row's own dates are never checked. A successor whose startDate equals the closing plan's startDate caps that plan at endDate == startDate — the zero-day plan plan.Validate rejects on every other write path. A successor starting earlier yields an inverted range. The overlap check cannot catch either: a zero-day half-open interval intersects nothing.Added an explicit guard rejecting closingEndDate <= closing.StartDate with the inverted_dates code.
majorfree band ending at 24:00 silently degradesplan.Validate accepts a free window ending at "24:00" and the editor can produce one (Q39 maps a 23:59 picker back to 24:00 on write), but derivedstats.ParseOffpeakWindow and FluxCore's DateFormatting.parseWindowTime both reject hour 24. Blocks, PeakPeriods, GridColor and CutoffTimeColor would therefore treat a day that does have a free band as having none. Q34 fixed this for band parsing but not for the window handoff.Both parsers now accept 24:00 as end-of-day. Go returns 1440, which isOffpeak's `minuteOfDay < end` already handles; Swift returns the following day's midnight via calendar arithmetic. Verified blocks.go degrades correctly to its two-block layout for such a window rather than misplacing a boundary. Updated the FluxCore test that pinned the old contract and added coverage for the new one.
minorinternal/poller/dailysummary.go — unbounded hourly waste on unpriced datesOnce socLow is written for a date no plan prices, needDerived goes false while the peak and band sentinels stay deliberately unset. Every subsequent hourly tick then ran a full-day readings query (~8,640 rows, ~1 MB), converted the whole slice, computed nothing, and issued a no-op write — forever. A regression: the pre-feature code returned before the readings query on this path.Added a gate after plan resolution that returns before the readings query when nothing can be computed, plus a distinct PassResultSkippedNoPlan metric dimension so unpriced dates are visible to an operator rather than indistinguishable from useful work. New test asserts the readings query is not reached; the existing first-pass behaviour is unchanged.
minorcmd/backfill-grid/main.go — code contradicted its own doc commentbackfillBands' comment says "only when that row already exists (GET first; an absent row is skipped, never created, per Decision 7)", but the code queried a full day of readings and ran the band integration before the GET, discarding the work when the row was absent.Moved the GetDailyEnergy existence check above the readings query. Verified the two skip-counter tests exercise disjoint cases, so the reordering does not change which counter increments.
minorinternal/api/status.go:270 — nil-dereference hazard in the 10s hot pathresp.Offpeak.ProjectedEndSoc = p was safe only because projectOffpeakEndSoc and buildOffpeak both derive nil-ness from the same todayWindow — an invariant held by a comment, not the code. Any future divergence in either function turns it into a nil panic on the endpoint the dashboard polls every 10 seconds.Added a resp.Offpeak != nil guard. Verified the invariant does currently hold, so this is defence, not a live bug.
minordynamo.OffpeakItem duplicated the tier-1 costing rulesOffpeakItem.Geometry/Usable reimplemented plan.OffpeakRow.Geometry/Usable, and the pre-feature 11:00-14:00 window was hardcoded in both. These two rules are exactly what tier-1 cost resolution turns on and are pinned by the shared cross-language vectors, so a second copy is a second answer to whether a day can be priced from its stored split.Added OffpeakItem.PlanRow(); Geometry and Usable now delegate to it, and the duplicated constants are gone. cmd/migrate-pricing's hand-built plan.OffpeakRow conversion now goes through the same method.
minorday.go / history.go — byte-equivalent band blocksThe today-live-vs-stored band split decision was written out twice, differing only in variable names. AC 3.4 requires the two endpoints to report the identical split for the same day, so the rule deciding which source to use is precisely the thing that should not exist twice.Extracted bandImportsFor(plans, date, isToday, readings, now, stored) in response.go; both endpoints call it. An absent daily-energy row now passes a nil stored split rather than needing its own branch.
minorSwift — duplicated cost-input constructionDaySummary.costInputs and DayEnergy.costInputs were identical 16-line bodies reconstructing the off-peak row from flat wire fields. Before this branch there was one implementation (DayEnergy forwarded through a transient DaySummary).Extracted OffpeakImport.from(...), which holds the two rules that matter: absent import means no row at all (not a zero row), and an absent sample count reads as zero. A protocol was tried first and rejected — DayEnergy.eInput is non-optional where DaySummary.eInput is optional, so they cannot share a property requirement. A 9-parameter factory was also tried and correctly rejected by SwiftLint's parameter-count rule.
minorhand-copied constants and duplicate validatorscmd/migrate-pricing redeclared the "__open_ended" sentinel id with a comment admitting it mirrors dynamo's unexported constant; api.validISODate duplicated plan.validDate; migrate-pricing declared derefRate and deref identically in the same package; internal/api/pricing.go re-exported seven plan validation codes that nothing referenced; PricingPlanDraft and PricingEditor used "Australia/Melbourne" (carried over from the deleted PricingPeriodDraft) where Q19 names Sydney and DateFormatting.sydneyTimeZone exists.Exported dynamo.PricingSentinelID and plan.ValidDate and routed the copies through them; deleted derefRate; replaced the dead code aliases with a doc comment naming the forwarded plan.Code* set (the used alias stays); switched both timezone strings to DateFormatting.sydneyTimeZone.
minorcmd/api/main_test.go — dead env setupThe test still set OFFPEAK_START/OFFPEAK_END, which loadConfig no longer reads, while omitting several TABLE_* vars that are now required — it passed only because TABLE_READINGS is checked first. Its comment claimed to set "all env vars except TABLE_READINGS".Driven off requiredEnvVars so the list cannot drift from what loadConfig demands.
minorsix stale documentsCLAUDE.md still described the off-peak window as SSM-configured — the first file every agent reads. specs/time-of-use-pricing/prerequisites.md instructed the operator to run cmd/migrate-pricing with --dry-run, a flag that does not exist (reporting is the default), on the production cutover step. docs/agent-notes/api-layer.md documented removed handler fields and two changed function signatures. docs/architecture-diagrams.md had the poller not reading flux-pricing, the old scheduler description, and the configured-window prose. docs/flux-v1.md described SSM as the window's home in three places. specs/ios-app/implementation.md documented the deleted defaultWindowStart/End fallback as current behaviour.All six updated. prerequisites.md now also notes the opposite --apply/--dry-run default versus the sibling backfill CLIs, since that divergence is the trap. design.md's "--dry-run default" phrasing corrected to match the tool.
minorAC 6.4 has no cross-language pinSegmentation and cost resolution are pinned to shared JSON vectors consumed by both Go and Swift tests. PricingPlanDraft.validate mirrors plan.Validate by hand with no shared fixture, so the two can drift silently and AC 6.4 would fail without any test noticing.Not fixed — adding a third vector set plus its consumers on both sides is a genuine piece of work, not a pre-push tidy. Recorded as a follow-up; see the double-check section.
minortoday's rated region integrated twice per requestlivePeakGridImport and liveBandImports integrate the same span, each computing five energy channels to use one (~1.2 MB scratch per request where ~120 KB would do). The poller deliberately fused these into dynamo.IntegrateRatedBands; the live path did not.Not fixed — with the DST fix the two now cover identical instants, so this is waste rather than a wrong number. Fusing them is a deliberate design change better made on purpose than at push time. See the double-check section.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +36 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex fefbeb6..0d97e2e 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,8 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +### Changed++- **Time-of-use pricing — app** (T-1890, T-1891). Fourth implementation phase of the band-based pricing spec, and the first with user-visible change. Settings ▸ Pricing now edits band-based plans, and costs are computed from them.+  - Settings ▸ Pricing lists plans rather than periods. Each row shows its date range and the bands its rates apply over — "Free 10:00–15:00 · $0.2800 01:00–06:00 · $0.3500 default" — with feed-in on its own line. A closed plan reads "2026-01-01 until 2026-08-01" rather than a dash range, because the end date is exclusive and that day belongs to the successor.+  - The plan editor replaces the three rate fields with a default rate, a feed-in rate, a savings reference rate (shown only once a free window exists), and a Windows section: per row a start and end picker, a Free toggle, and a rate field when the window isn't free. Times outside every window take the default rate, so a plan cannot be entered with a gap. Client-side validation mirrors the server's band rules, so a plan the editor accepts is not rejected for something it could have caught.+  - The succession affordance now says what it does: it ends the open-ended plan **on** the new plan's start date and starts the successor that same day, rather than the day before. Both rows carry the same literal date.+  - Day costs resolve in three tiers, matching the backend figure for figure: the stored per-band split when its geometry matches the plan and the free band's import is resolvable; otherwise the pre-band single-rate formula, which is what keeps every historical day's cost unchanged; otherwise all import at the plan's highest rate with no savings. Both cost helpers are pinned to the same cross-language vectors the Go implementation is tested against. The cost card and History totals keep their existing layout.+  - Day Detail chart shading takes the free window from the plan pricing that day instead of a hardcoded 11:00–14:00, so it moves on the switch date. A day with no plan, or whose plan has no free band, gets no shading rather than a misleading band — and the widgets no longer substitute the legacy window when the API reports none.+  - `/day` and `/history` now also report the window and integration provenance of the off-peak row each day's off-peak import came from. Without it the app could not tell a split captured under the current free window from a stale one, and every day priced by the new plan would have fallen back to the highest-rate estimate. The History cache stores it too, so an offline day prices identically to an online one.++### Internal++- **Time-of-use pricing — poller and operator tools** (T-1890, T-1891). Third implementation phase of the band-based pricing spec. The poller now takes its off-peak window from the plan pricing each day, captures the durable per-band split, and the operator tools follow suit.+  - New `PlanSource` gives the poller a read-through view of the pricing table with a last-good cache. A failed read is served from the cache with a warning and is never resolved as "no plan" — that would silently strip a day of its free window and its band split. A cold start with an unreachable table retries with backoff before giving up.+  - The off-peak scheduler's daily cycle is re-anchored to local midnight: it wakes, refreshes the plans, resolves that day's free window, and sleeps to its start. Plans only change behaviour at midnight, so one refresh per day is exactly enough and a plan switch changes the window with no reconfiguration. A day whose plan has no free band sleeps through to the next midnight; an unreadable pricing table retries within the day rather than writing the day off.+  - Window-end finalisation no longer requires a pending row or a start snapshot. The five deltas have come from integrating readings since T-1341 — the snapshot is diagnostics only — so a plan-read failure at window start now costs forensics rather than the whole day, and a restart or a late plan load still closes the window. Finalised rows record the window geometry they were integrated under, so a later plan edit is detectable instead of silently repricing the day.+  - The summarisation pass replaces its single unresolved-window early return with per-outcome gating. A plan read failure writes nothing and is retried; a plan with a free band runs every block; a plan without one runs the window-independent blocks with the whole day rated; a day no plan prices still gets its lowest-SoC figure, with the band sentinel left unset so a backfill can capture the split later. The `skipped-ssm-unresolved` metric dimension is gone.+  - The pass gains the rated-band capture: `bandImports` and `peakGridImportKwh` now come from one integration over the plan's rated segments, so the two can no longer disagree. Segment boundaries come from wall-clock time, which also retires the latent hour-off bug the peak block carried on DST days.+  - `cmd/backfill-grid` and `cmd/backfill-solar` resolve each day's window from the pricing table instead of `--offpeak-start`/`--offpeak-end`. A backfill spanning a plan switch needs a different window on either side; static flags would have silently misattributed every day on the wrong side. `backfill-grid` additionally rewrites the day's rated `bandImports` and the off-peak row's window geometry. The two writes go to different tables in separate calls — non-atomic, but idempotent and re-runnable.+  - New `cmd/migrate-pricing` converts the legacy three-rate rows to the band shape once. It prices every retained day under the pre-migration rules using an independently written copy of the three-rate formula, transforms the rows, prices every day again under the band model, and aborts before a single write if any day differs. Dry-run by default; `--apply` writes full items preserving row ids and leaving the sentinel alone; rows already in the band shape are skipped, so re-running is a no-op.+  - `OFFPEAK_START`/`OFFPEAK_END` and the `OffPeakWindowStart`/`OffPeakWindowEnd` CloudFormation parameters are gone from both containers. The poller container gains `TABLE_PRICING` and read-only access (`Scan`/`GetItem`/`Query`) to the pricing table; the Lambda keeps sole write access. Deploying this version leaves `/flux/offpeak-start` and `/flux/offpeak-end` behind as orphaned SSM parameters that nothing reads.++- **Time-of-use pricing — Lambda API** (T-1890, T-1891). Second implementation phase of the band-based pricing spec; the API now speaks the band shape and derives the off-peak window from plans.+  - `/pricing` CRUD and `replace-open-ended` take and return the band shape (`defaultRate` + `windows` + `savingsReferenceRate`), with the exclusive end date stored exactly as sent. Validation reports the violated band rule (`band_window_invalid`, `band_overlap`, `multiple_free_bands`, `savings_rate_missing`, `no_rated_band`) alongside the existing rate and date rules, and date-range overlaps now name the conflicting plan. A pre-migration three-rate payload is rejected with `legacy_shape`, detected on the raw JSON keys because `encoding/json` silently drops unknown fields and would otherwise decode it as a zero-rate band plan.+  - `/status`, `/day`, and `/history` resolve the off-peak window from the plan pricing the day in question instead of the `OFFPEAK_START`/`OFFPEAK_END` environment variables, which are gone from the Lambda. Cutoff suppression and charge projection take the window from the plan pricing the day the *next* window falls on, so on the eve of a plan switch they follow the successor's window rather than the outgoing one.+  - `/status.offpeak` is now nullable: a day whose plan has no free band — or that no plan prices — serialises `null` rather than emitting a window, so clients render "no window" instead of substituting a default. Off-peak and peak values on such a day are absent, never zero. A pricing read failure fails the request rather than resolving as "no plan".+  - `/day` and `/history` gain a nullable `bandImports` array (rated bands only; the free band's import stays in `offpeakGridImportKwh`). Past days serve the split captured at day close; today's is integrated live from readings through a single shared helper both endpoints call, so the two screens cannot disagree. A band the clock has not reached reads zero, but a started band that cannot be integrated makes the whole split unavailable. `/status` does not carry the split.++- **Time-of-use pricing — Go plan domain and data layer** (T-1890, T-1891). First implementation phase of the band-based pricing spec; no user-visible change yet.+  - New leaf package `internal/plan` holding the plan domain: a default rate plus exception windows, the derived full-day segmentation, per-date plan selection, free-window resolution, and DST-correct wall-clock segment bounds. Band boundaries use a new parser that accepts `24:00` as end-of-day — `derivedstats.ParseOffpeakWindow` rejects hours above 23 and would reject every plan. Validation covers the band rules (invalid window, overlap, multiple free bands, missing savings rate, no rated band) alongside the existing rate bounds and precision rules.+  - Plan end dates are now **exclusive**: a plan ending on the date its successor starts hands that whole day to the successor, with no ±1 arithmetic in validation, succession, or display. `ReplaceOpenEnded` writes the same literal switch date to both rows and refuses a succession whose closing row is still the pre-migration shape.+  - Three-tier day-cost resolution in Go (stored band split → the pre-band single-rate formula verbatim → highest-rate fallback), pinned together with the segmentation to shared cross-language vectors in `internal/api/testdata/` that the FluxCore implementation will be held to. The single-rate tier is what keeps every historical day's cost identical after migration.+  - Storage moves to the band shape: `flux-pricing` rows store `defaultRate` + `windows` + `savingsReferenceRate`, `flux-daily-energy` gains a sentinel-gated `bandImports` group for the durable per-band split, and `flux-offpeak` rows snapshot the free-window geometry they were integrated under. Pre-migration rows are detected on the raw attribute map and converted on read, so band-aware services can deploy ahead of the migration run.+  - Property-based tests assert the segments always tile 00:00–24:00, that abutting same-rate segments stay separate, that at most one plan prices any date, and that per-segment grid-import integrals sum to the whole-day integral on 23-, 24-, and 25-hour Sydney days.+ ### Documentation +- **Time-of-use pricing spec** (T-1890, T-1891; `specs/time-of-use-pricing/`). Full spec for reworking pricing plans into daily time bands — a default rate plus exception windows (the incoming plan: free 10:00–15:00, cheaper 01:00–06:00, standard rate otherwise) — with same-day plan succession via exclusive end dates, and the active plan replacing the SSM off-peak window as the source of truth across the poller and API. Covers durable per-band import capture at day close (so banded costs outlive the 30-day readings TTL), a three-tier FluxCore cost resolution that keeps all historical costs identical, a one-time `cmd/migrate-pricing` CLI with golden-value verification, requirements, design, decision log (6 ADRs, 36 quick decisions), a 39-task TDD implementation plan in three parallel streams, and manual cutover prerequisites. Planning artifacts only — no code changes. - **Architecture diagrams** (`docs/architecture-diagrams.md`). A reference document with eight Mermaid diagrams covering the system overview, build and deploy pipeline, AWS infrastructure, the poller's polling engine, the DynamoDB data model (11 tables), the Lambda API surface, the dashboard refresh sequence, and the SoC-alert push flow. Each diagram is drawn from the source of truth (`infrastructure/template.yaml`, the Go services, and the apps) and validated against the Mermaid parser. Intended both as documentation and as source material for a write-up about Flux. - Corrected the DynamoDB table list in `CLAUDE.md` to enumerate all 11 tables with their retention policy (it previously listed only the original 5). 
CLAUDE.md Modified +2 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex 02d615e..ab3208b 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -22,7 +22,8 @@ iOS App --> Lambda Function URL (Go, ARM64) -------------+ - **Poller** polls AlphaESS on multiple schedules (10s for live data, hourly/6h/24h for summaries) and writes to DynamoDB - **Lambda API** reads from DynamoDB and computes derived stats (rolling averages, cutoff estimates, off-peak deltas, peak usage periods) - **DynamoDB tables** (11, all PAY_PER_REQUEST): `flux-readings` (TTL 30d), `flux-daily-power`, `flux-daily-energy`, `flux-system`, `flux-offpeak`, `flux-notes` (PITR), `flux-devices` (PITR), `flux-soc-rules` (PITR), `flux-soc-fire-state` (TTL 7d), `flux-pricing` (PITR), `flux-simulation-presets` (PITR)-- Off-peak energy is computed by integrating `flux-readings` over the SSM-configured window (11:00-14:00) in the poller's `handleEnd`; the `getOneDateEnergy` snapshots captured at window start/end are retained for diagnostics only (post-T-1341, `specs/offpeak-from-readings`)+- Off-peak energy is computed by integrating `flux-readings` over the free window in the poller's `handleEnd`; the `getOneDateEnergy` snapshots captured at window start/end are retained for diagnostics only (post-T-1341, `specs/offpeak-from-readings`)+- The free (off-peak) window is a property of the pricing plan covering each day, not configuration: every consumer resolves it from the free band of the plan pricing the day in question, so it changes with the plan rather than with a stack update (`specs/time-of-use-pricing`, Decision 2). The `OffPeakWindowStart`/`OffPeakWindowEnd` SSM parameters are gone  Visual reference for the full flow (AWS infrastructure, polling engine, data model, API surface, dashboard and push sequences): `docs/architecture-diagrams.md`. 
cmd/api/main_test.go Modified +6 / -11
diff --git a/cmd/api/main_test.go b/cmd/api/main_test.goindex dfa0413..0d425f2 100644--- a/cmd/api/main_test.go+++ b/cmd/api/main_test.go@@ -9,18 +9,13 @@ import ( )  func TestLoadConfigMissingEnv(t *testing.T) {-	// Set all env vars except TABLE_READINGS to verify validation catches it.+	// Set every required env var except TABLE_READINGS to verify validation+	// catches it. Driven off requiredEnvVars so the list cannot drift out of+	// step with what loadConfig actually demands.+	for _, key := range requiredEnvVars {+		t.Setenv(key, "placeholder")+	} 	t.Setenv("TABLE_READINGS", "")-	t.Setenv("TABLE_DAILY_ENERGY", "flux-daily-energy")-	t.Setenv("TABLE_DAILY_POWER", "flux-daily-power")-	t.Setenv("TABLE_SYSTEM", "flux-system")-	t.Setenv("TABLE_OFFPEAK", "flux-offpeak")-	t.Setenv("TABLE_NOTES", "flux-notes")-	t.Setenv("TABLE_PRICING", "flux-pricing")-	t.Setenv("OFFPEAK_START", "11:00")-	t.Setenv("OFFPEAK_END", "14:00")-	t.Setenv("API_TOKEN_PARAM", "/flux/api-token")-	t.Setenv("SYSTEM_SERIAL_PARAM", "/flux/system-serial")  	_, err := loadConfig(context.Background()) 	require.Error(t, err)
cmd/api/main.go Modified +19 / -25
diff --git a/cmd/api/main.go b/cmd/api/main.goindex 99e074c..d246bae 100644--- a/cmd/api/main.go+++ b/cmd/api/main.go@@ -20,17 +20,15 @@ import (  // config holds all resolved configuration for the Lambda. type config struct {-	reader       dynamo.Reader-	notes        api.NoteWriter-	devices      api.DeviceStore-	rules        api.SocRuleStore-	fireState    api.FireStateCleaner-	pricing      api.PricingStore-	presets      api.SimulationPresetStore-	apiToken     string-	serial       string-	offpeakStart string-	offpeakEnd   string+	reader    dynamo.Reader+	notes     api.NoteWriter+	devices   api.DeviceStore+	rules     api.SocRuleStore+	fireState api.FireStateCleaner+	pricing   api.PricingStore+	presets   api.SimulationPresetStore+	apiToken  string+	serial    string }  // requiredEnvVars lists environment variables that must be set.@@ -46,8 +44,6 @@ var requiredEnvVars = []string{ 	"TABLE_SOC_FIRESTATE", 	"TABLE_PRICING", 	"TABLE_SIMULATION_PRESETS",-	"OFFPEAK_START",-	"OFFPEAK_END", 	"API_TOKEN_PARAM", 	"SYSTEM_SERIAL_PARAM", }@@ -62,7 +58,7 @@ func main() { 		os.Exit(1) 	} -	handler := api.NewHandler(cfg.reader, cfg.notes, cfg.serial, cfg.apiToken, cfg.offpeakStart, cfg.offpeakEnd)+	handler := api.NewHandler(cfg.reader, cfg.notes, cfg.serial, cfg.apiToken) 	handler.SetDeviceStore(cfg.devices) 	handler.SetSocRuleStore(cfg.rules) 	handler.SetFireStateCleaner(cfg.fireState)@@ -125,17 +121,15 @@ func loadConfig(ctx context.Context) (*config, error) { 	presets := dynamo.NewDynamoSimulationPresetStore(ddbClient, os.Getenv("TABLE_SIMULATION_PRESETS"))  	return &config{-		reader:       reader,-		notes:        notes,-		devices:      devices,-		rules:        socRuleStoreAdapter{reader: ruleReader, writer: ruleWriter},-		fireState:    fireStateCleanerAdapter{store: fireState},-		pricing:      pricingStoreAdapter{store: pricing},-		presets:      presets,-		apiToken:     apiToken,-		serial:       serial,-		offpeakStart: os.Getenv("OFFPEAK_START"),-		offpeakEnd:   os.Getenv("OFFPEAK_END"),+		reader:    reader,+		notes:     notes,+		devices:   devices,+		rules:     socRuleStoreAdapter{reader: ruleReader, writer: ruleWriter},+		fireState: fireStateCleanerAdapter{store: fireState},+		pricing:   pricingStoreAdapter{store: pricing},+		presets:   presets,+		apiToken:  apiToken,+		serial:    serial, 	}, nil } 
cmd/backfill-grid/main_test.go Modified +46 / -4
diff --git a/cmd/backfill-grid/main_test.go b/cmd/backfill-grid/main_test.goindex 6ee7867..7b8285b 100644--- a/cmd/backfill-grid/main_test.go+++ b/cmd/backfill-grid/main_test.go@@ -26,6 +26,7 @@ const ( 	testOffpeakTable     = "flux-offpeak-test" 	testReadingsTable    = "flux-readings-test" 	testDailyEnergyTable = "flux-daily-energy-test"+	testPricingTable     = "flux-pricing-test" )  // fakeDynamo is a lightweight in-memory stand-in for the DynamoDB client.@@ -36,12 +37,51 @@ type fakeDynamo struct { 	offpeakRows       map[string][]dynamo.OffpeakItem   // keyed by "*" 	readingsByDate    map[string][]dynamo.ReadingItem   // keyed by Sydney YYYY-MM-DD 	dailyEnergyByDate map[string]dynamo.DailyEnergyItem // keyed by Sydney YYYY-MM-DD; absent = no row+	pricingRows       []dynamo.PricingItem              // nil ⇒ the default 11:00–14:00 open-ended plan 	location          *time.Location 	queries           []*dynamodb.QueryInput 	puts              []*dynamodb.PutItemInput 	updates           []*dynamodb.UpdateItemInput // records UpdateDailyEnergyDerived calls 	queryErrForTable  map[string]error 	putErr            error+	scanErr           error+}++// Scan serves the pricing read. A fixture that sets no pricingRows gets the+// pre-feature plan — free 11:00–14:00, one flat rate — which is the shape+// every date in these tests was originally priced under.+func (f *fakeDynamo) Scan(_ context.Context, _ *dynamodb.ScanInput, _ ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) {+	if f.scanErr != nil {+		return nil, f.scanErr+	}+	rows := f.pricingRows+	if rows == nil {+		rows = []dynamo.PricingItem{testPricingRow("legacy-equivalent", "2000-01-01", "", "11:00", "14:00")}+	}+	avs := make([]map[string]types.AttributeValue, 0, len(rows))+	for i := range rows {+		av, err := attributevalue.MarshalMap(rows[i])+		if err != nil {+			return nil, err+		}+		avs = append(avs, av)+	}+	return &dynamodb.ScanOutput{Items: avs}, nil+}++// testPricingRow builds a band-shape plan whose free window is the given+// range and whose remainder carries a single flat rate.+func testPricingRow(id, startDate, endDate, freeStart, freeEnd string) dynamo.PricingItem {+	savings := 0.35+	item := dynamo.PricingItem{+		PricingID: id, StartDate: startDate, DefaultRate: 0.35, FeedInRate: 0.05,+		Windows:              []dynamo.PricingWindow{{Start: freeStart, End: freeEnd, Free: true}},+		SavingsReferenceRate: &savings,+	}+	if endDate != "" {+		item.EndDate = &endDate+	}+	return item }  func (f *fakeDynamo) Query(_ context.Context, params *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {@@ -248,10 +288,9 @@ func backfillOptsForTest(loc *time.Location, dates ...string) backfillOpts { 		tableOffpeak:     testOffpeakTable, 		tableReadings:    testReadingsTable, 		tableDailyEnergy: testDailyEnergyTable,+		tablePricing:     testPricingTable, 		from:             from, 		to:               to,-		offpeakStart:     "11:00",-		offpeakEnd:       "14:00", 		location:         loc, 		now:              func() time.Time { return now }, 	}@@ -747,13 +786,16 @@ func TestValidateOpts_RejectsReversedDateRange(t *testing.T) { 	assert.Contains(t, err.Error(), "after") } -func TestValidateOpts_RejectsMissingWindow(t *testing.T) {+// The window flags are gone — the plan supplies each day's window — so the+// pricing table takes their place as a required option.+func TestValidateOpts_RejectsMissingPricingTable(t *testing.T) { 	loc := sydney(t) 	opts := backfillOptsForTest(loc, "2026-05-18")-	opts.offpeakStart = ""+	opts.tablePricing = ""  	err := validateOpts(opts) 	require.Error(t, err)+	assert.Contains(t, err.Error(), "table-pricing") }  func decodeOffpeakItem(t *testing.T, av map[string]types.AttributeValue) dynamo.OffpeakItem {
cmd/backfill-grid/main.go Modified +196 / -129
diff --git a/cmd/backfill-grid/main.go b/cmd/backfill-grid/main.goindex 7173163..5c432a5 100644--- a/cmd/backfill-grid/main.go+++ b/cmd/backfill-grid/main.go@@ -1,24 +1,34 @@-// Package main is the standalone backfill CLI for the two readings-derived-// grid-import channels: off-peak (off-peak from readings, T-1341) and peak-// (peak from readings; Decision 7 renamed this tool from backfill-offpeak).+// Package main is the standalone backfill CLI for the readings-derived+// grid-import channels: off-peak (off-peak from readings, T-1341), peak, and+// the per-band split (time-of-use pricing).+//+// Each day's free window comes from the plan pricing that day, read from the+// pricing table — not from flags. A backfill spanning a plan switch needs a+// different window on either side of it, and a static flag pair would silently+// misattribute every day on the wrong side (Q24). // // For each non-today date in a range it does two things: //-//  1. Off-peak (unchanged): recomputes the five flux-offpeak energy deltas-//     (gridUsageKwh, solarKwh, batteryChargeKwh, batteryDischargeKwh,-//     gridExportKwh) by integrating the power channels from flux-readings over-//     the SSM off-peak window, writing via WriteOffpeakIfComplete so a row-//     mid-poll (pending or absent) is never overwritten (AC 7.8).+//  1. Off-peak: recomputes the five flux-offpeak energy deltas (gridUsageKwh,+//     solarKwh, batteryChargeKwh, batteryDischargeKwh, gridExportKwh) by+//     integrating the power channels from flux-readings over the day's free+//     window, writing via WriteOffpeakIfComplete so a row mid-poll (pending or+//     absent) is never overwritten (AC 7.8). The row also re-records the+//     window geometry it was integrated under, so a later plan edit shows up+//     as a mismatch instead of silently repricing the day (Q23/Q31). //-//  2. Peak: computes peakGridImportKwh by integrating max(pgrid,0) over the-//     two windows bracketing off-peak ([dayStart, offpeakStart) and-//     [offpeakEnd, dayEnd)) and writes it plus the peakComputedAt sentinel to-//     the corresponding flux-daily-energy row via UpdateDailyEnergyDerived-//     (peak group only — the derived-stats group is left untouched). The+//  2. Peak and bands: integrates max(pgrid,0) over each rated segment of the+//     day's plan and writes both the per-band split (bandImports) and their+//     total (peakGridImportKwh) to the flux-daily-energy row via+//     UpdateDailyEnergyDerived — the derived-stats group is left untouched.+//     Both values come from one integration so they cannot disagree. The //     daily-energy row is fetched first; if it is absent the date is skipped-//     for peak (no phantom-row creation, Decision 7). If the integration's-//     usability gate fails for either sub-window the date keeps the iOS-//     fallback (Decision 4).+//     (no phantom-row creation, Decision 7). If any rated segment fails the+//     usability gate the date keeps the client-side fallback (Decision 4).+//+// The two steps write to different tables in separate calls, so a run+// interrupted between them leaves one repaired and the other not. Both writes+// are idempotent, so the fix is to re-run the same command. // // Today's row is always skipped on both sides — the poller is the single // authoritative writer for today (AC 7.2 / Decision 4).@@ -28,10 +38,10 @@ //	go run ./cmd/backfill-grid \ //	    --serial=AB1234 \ //	    --from=2026-04-19 --to=2026-05-18 \-//	    --offpeak-start=11:00 --offpeak-end=14:00 \ //	    --table-offpeak=flux-offpeak \ //	    --table-readings=flux-readings \ //	    --table-daily-energy=flux-daily-energy \+//	    --table-pricing=flux-pricing \ //	    [--dry-run] // // Defaults: from = today - 30d, to = yesterday (the practical readings TTL@@ -48,6 +58,7 @@ import ( 	"math" 	"os" 	"strconv"+	"strings" 	"time"  	_ "time/tzdata"@@ -59,6 +70,7 @@ import (  	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" )  // dynamoAPI is the subset of the DynamoDB client this CLI uses. It mirrors@@ -72,6 +84,9 @@ type dynamoAPI interface { 	GetItem(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) 	UpdateItem(ctx context.Context, params *dynamodb.UpdateItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error) 	BatchWriteItem(ctx context.Context, params *dynamodb.BatchWriteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.BatchWriteItemOutput, error)+	// Scan serves the pricing read (ListPricingRows). The CLI never writes+	// pricing rows — the Lambda keeps sole write access to that table.+	Scan(ctx context.Context, params *dynamodb.ScanInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) }  type backfillOpts struct {@@ -79,10 +94,9 @@ type backfillOpts struct { 	tableOffpeak     string 	tableReadings    string 	tableDailyEnergy string+	tablePricing     string 	from             string 	to               string-	offpeakStart     string-	offpeakEnd       string 	location         *time.Location 	dryRun           bool 	now              func() time.Time@@ -95,13 +109,16 @@ type backfillResult struct { 	RowsDryRun          int 	RowsSparseSkipped   int // <2 usable readings in window per AC 7.4 	RowsConditionFailed int // WriteOffpeakIfComplete condition rejected--	// Peak accounting (Decision 7). Independent of the off-peak counters-	// above: a date can have its off-peak row written and its peak skipped, or-	// vice versa.-	PeakWritten       int // peakGridImportKwh written (or would be, in dry-run)+	RowsNoPlan          int // no plan prices the date — window unknowable+	RowsNoFreeBand      int // the date's plan has no free window to recompute++	// Peak/band accounting (Decision 7). Independent of the off-peak counters+	// above: a date can have its off-peak row written and its bands skipped, or+	// vice versa. Peak and bands share these counters because they come from+	// one integration and are written together.+	PeakWritten       int // peakGridImportKwh + bandImports written (or would be, in dry-run) 	PeakSkippedAbsent int // flux-daily-energy row absent — skipped (no phantom row)-	PeakSkippedSparse int // peak integration usability gate failed for a sub-window+	PeakSkippedSparse int // a rated segment failed the integrator's usability gate  	Summary []string }@@ -123,10 +140,9 @@ func main() { 	flag.StringVar(&opts.tableOffpeak, "table-offpeak", os.Getenv("TABLE_OFFPEAK"), "flux-offpeak table name (or env TABLE_OFFPEAK)") 	flag.StringVar(&opts.tableReadings, "table-readings", os.Getenv("TABLE_READINGS"), "flux-readings table name (or env TABLE_READINGS)") 	flag.StringVar(&opts.tableDailyEnergy, "table-daily-energy", os.Getenv("TABLE_DAILY_ENERGY"), "flux-daily-energy table name (or env TABLE_DAILY_ENERGY)")+	flag.StringVar(&opts.tablePricing, "table-pricing", os.Getenv("TABLE_PRICING"), "flux-pricing table name (or env TABLE_PRICING)") 	flag.StringVar(&opts.from, "from", defaultFrom, "start date inclusive (YYYY-MM-DD, Sydney TZ)") 	flag.StringVar(&opts.to, "to", defaultTo, "end date inclusive (YYYY-MM-DD, Sydney TZ)")-	flag.StringVar(&opts.offpeakStart, "offpeak-start", os.Getenv("OFFPEAK_START"), "off-peak window start HH:MM (or env OFFPEAK_START)")-	flag.StringVar(&opts.offpeakEnd, "offpeak-end", os.Getenv("OFFPEAK_END"), "off-peak window end HH:MM (or env OFFPEAK_END)") 	flag.BoolVar(&opts.dryRun, "dry-run", false, "log intended writes without invoking PutItem") 	flag.Parse() @@ -158,9 +174,11 @@ func main() { 		"todaySkipped", res.RowsSkipped, 		"sparseSkipped", res.RowsSparseSkipped, 		"conditionFailed", res.RowsConditionFailed,-		"peakWritten", res.PeakWritten,-		"peakSkippedAbsentRow", res.PeakSkippedAbsent,-		"peakSkippedSparse", res.PeakSkippedSparse,+		"noPlan", res.RowsNoPlan,+		"noFreeBand", res.RowsNoFreeBand,+		"bandsWritten", res.PeakWritten,+		"bandsSkippedAbsentRow", res.PeakSkippedAbsent,+		"bandsSkippedSparse", res.PeakSkippedSparse, 	) } @@ -177,8 +195,8 @@ func validateOpts(o backfillOpts) error { 	if o.tableDailyEnergy == "" { 		return fmt.Errorf("--table-daily-energy is required") 	}-	if o.offpeakStart == "" || o.offpeakEnd == "" {-		return fmt.Errorf("--offpeak-start and --offpeak-end are required")+	if o.tablePricing == "" {+		return fmt.Errorf("--table-pricing is required") 	} 	from, err := time.ParseInLocation("2006-01-02", o.from, o.location) 	if err != nil {@@ -191,9 +209,6 @@ func validateOpts(o backfillOpts) error { 	if from.After(to) { 		return fmt.Errorf("--from %s is after --to %s", o.from, o.to) 	}-	if _, _, ok := derivedstats.ParseOffpeakWindow(o.offpeakStart, o.offpeakEnd); !ok {-		return fmt.Errorf("invalid --offpeak-start %q / --offpeak-end %q", o.offpeakStart, o.offpeakEnd)-	} 	return nil } @@ -205,15 +220,24 @@ func validateOpts(o backfillOpts) error { // fewer than two usable readings in the window emit a SKIPPED summary line // and are left unchanged (AC 7.4). //-// For each non-today date it also backfills peakGridImportKwh on the-// corresponding flux-daily-energy row (Decision 7): it queries the full-// Sydney-local day's readings, integrates max(pgrid,0) over the two windows-// bracketing off-peak, and — only when the daily-energy row already exists —-// writes the peak group via UpdateDailyEnergyDerived. An absent row is skipped-// for peak (no phantom-row creation); a failed usability gate leaves the date-// on the iOS fallback (Decision 4). The off-peak recompute above is unchanged.+// For each non-today date it also backfills peakGridImportKwh and bandImports+// on the corresponding flux-daily-energy row: it queries the full Sydney-local+// day's readings, integrates max(pgrid,0) over each rated segment of the day's+// plan, and — only when the daily-energy row already exists — writes the peak+// and band groups via UpdateDailyEnergyDerived. An absent row is skipped (no+// phantom-row creation); a failed usability gate leaves the date on the+// client-side fallback (Decision 4).+//+// Every window comes from the plan pricing that particular date, so a range+// spanning a plan switch repairs each side under its own window (Q24). func runBackfill(ctx context.Context, client dynamoAPI, opts backfillOpts) (*backfillResult, error) { 	res := &backfillResult{}+	plans, err := dynamo.ListPricingRows(ctx, client, opts.tablePricing)+	if err != nil {+		return nil, fmt.Errorf("list pricing (%s): %w", opts.tablePricing, err)+	}+	domainPlans := dynamo.PlansFromItems(plans)+ 	rows, err := queryOffpeakRange(ctx, client, opts.tableOffpeak, opts.serial, opts.from, opts.to) 	if err != nil { 		return nil, fmt.Errorf("query offpeak (%s): %w", opts.tableOffpeak, err)@@ -240,28 +264,36 @@ func runBackfill(ctx context.Context, client dynamoAPI, opts backfillOpts) (*bac 			slog.Warn("skip: invalid row date", "date", row.Date, "error", err) 			continue 		}-		windowStart, windowEnd, ok := offpeakBoundaries(day, opts.location, opts.offpeakStart, opts.offpeakEnd)-		if !ok {-			res.RowsSkipped++-			slog.Warn("skip: invalid offpeak window", "date", row.Date)++		datePlan, hasPlan := plan.PlanFor(domainPlans, row.Date)+		if !hasPlan {+			// Without a plan there is no window to integrate over and no+			// segmentation to capture — repairing the day would mean inventing+			// its geometry.+			res.RowsNoPlan+++			slog.Warn("skip: no plan prices this date", "date", row.Date) 			continue 		} -		// Off-peak recompute (unchanged behaviour). Its own query, its own-		// usability gate, its own conditional write — none of which gate the-		// peak side below (Decision 7).-		if err := backfillOffpeak(ctx, store, client, opts, row, windowStart, windowEnd, res); err != nil {-			return res, err+		// Off-peak recompute. Its own query, its own usability gate, its own+		// conditional write — none of which gate the band side below+		// (Decision 7). A plan with no free band has no off-peak row to+		// repair, but its rated bands still cover the whole day.+		if win, ok := freeWindowOn(datePlan, day, opts.location); ok {+			if err := backfillOffpeak(ctx, store, client, opts, row, win, res); err != nil {+				return res, err+			}+		} else {+			res.RowsNoFreeBand+++			slog.Info("skip off-peak recompute: the date's plan has no free band", "date", row.Date) 		} -		// Peak backfill. Independent of the off-peak outcome above: a sparse or-		// condition-rejected off-peak row does not stop peak from being written-		// (and vice versa). dayStart is Sydney-local midnight, dayEnd the next-		// local midnight (DST-correct via AddDate).+		// Peak and band backfill. Independent of the off-peak outcome above: a+		// sparse or condition-rejected off-peak row does not stop the bands+		// from being written (and vice versa). dayStart is Sydney-local+		// midnight (DST-correct via time.Date). 		dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, opts.location)-		dayEnd := dayStart.AddDate(0, 0, 1)-		if err := backfillPeak(ctx, store, client, opts, row.Date,-			dayStart, windowStart, windowEnd, dayEnd, res); err != nil {+		if err := backfillBands(ctx, store, client, opts, row.Date, datePlan, dayStart, res); err != nil { 			return res, err 		} 	}@@ -269,22 +301,50 @@ func runBackfill(ctx context.Context, client dynamoAPI, opts backfillOpts) (*bac 	return res, nil } +// offpeakWindow is one day's free window resolved to absolute local bounds,+// alongside the HH:MM geometry re-recorded on the repaired row.+type offpeakWindow struct {+	Start, End time.Time+	StartHHMM  string+	EndHHMM    string+}++// freeWindowOn resolves the plan's free band onto the given local day. ok is+// false when the plan has no free band.+func freeWindowOn(p plan.Plan, day time.Time, loc *time.Location) (offpeakWindow, bool) {+	startMin, endMin, ok := p.FreeWindowMinutes()+	if !ok {+		return offpeakWindow{}, false+	}+	midnight := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)+	at := func(minutes int) time.Time {+		// time.Date normalises out-of-range fields in the location, so a+		// "24:00" boundary lands on the next local midnight DST-correctly.+		return time.Date(midnight.Year(), midnight.Month(), midnight.Day(), minutes/60, minutes%60, 0, 0, loc)+	}+	return offpeakWindow{+		Start:     at(startMin),+		End:       at(endMin),+		StartHHMM: plan.FormatBandTime(startMin),+		EndHHMM:   plan.FormatBandTime(endMin),+	}, true+}+ // backfillOffpeak recomputes the five off-peak deltas for one date and writes-// the patched flux-offpeak row via WriteOffpeakIfComplete. Behaviour is-// unchanged from the original backfill-offpeak CLI: sparse readings and+// the patched flux-offpeak row via WriteOffpeakIfComplete. Sparse readings and // conditional-write rejections are recorded on res and skipped (not fatal); // only a readings-query error is fatal. func backfillOffpeak(ctx context.Context, store *dynamo.DynamoStore, client dynamoAPI,-	opts backfillOpts, row dynamo.OffpeakItem, windowStart, windowEnd time.Time, res *backfillResult,+	opts backfillOpts, row dynamo.OffpeakItem, win offpeakWindow, res *backfillResult, ) error { 	readings, err := queryReadingsRange(ctx, client, opts.tableReadings, opts.serial,-		windowStart.Unix(), windowEnd.Unix())+		win.Start.Unix(), win.End.Unix()) 	if err != nil { 		return fmt.Errorf("query readings (date=%s): %w", row.Date, err) 	}  	deltas, ok := derivedstats.IntegrateOffpeakDeltas(-		toDerivedReadings(readings), windowStart.Unix(), windowEnd.Unix())+		toDerivedReadings(readings), win.Start.Unix(), win.End.Unix()) 	if !ok { 		res.RowsSparseSkipped++ 		line := fmt.Sprintf("%s  SKIPPED (sparse readings; <2 usable samples in window)", row.Date)@@ -293,7 +353,7 @@ func backfillOffpeak(ctx context.Context, store *dynamo.DynamoStore, client dyna 		return nil 	} -	patched := patchOffpeakRow(row, deltas, opts.now().UTC())+	patched := patchOffpeakRow(row, deltas, opts.now().UTC(), win) 	summary := summaryLine(row.Date, row, patched) 	res.Summary = append(res.Summary, summary) @@ -323,87 +383,90 @@ func backfillOffpeak(ctx context.Context, store *dynamo.DynamoStore, client dyna 	return nil } -// backfillPeak computes peakGridImportKwh over the two windows bracketing-// off-peak ([dayStart, offpeakStart) and [offpeakEnd, dayEnd)) and writes it,-// plus the peakComputedAt sentinel, to the flux-daily-energy row for date — but+// backfillBands integrates max(pgrid,0) over each rated segment of the date's+// plan and writes both the split (bandImports) and its total+// (peakGridImportKwh), plus their sentinels, to the flux-daily-energy row — but // only when that row already exists (GET first; an absent row is skipped, never-// created, per Decision 7). When the integration's usability gate fails for-// either sub-window the date is skipped for peak and keeps the iOS fallback-// (Decision 4). Only the peak group is written (DerivedStatsComputedAt left-// empty), so the derived-stats group is untouched. Honours --dry-run.-func backfillPeak(ctx context.Context, store *dynamo.DynamoStore, client dynamoAPI,-	opts backfillOpts, date string, dayStart, offpeakStart, offpeakEnd, dayEnd time.Time, res *backfillResult,+// created, per Decision 7).+//+// The two values come from one integration on purpose: peak import is by+// definition the import outside the free window, which is the sum of the rated+// bands. Computing them separately would let the number on a cost card differ+// from the number on a stats card.+//+// When any rated segment fails the usability gate the date is skipped and keeps+// the client-side fallback (Decision 4). Only the peak and band groups are+// written, so the derived-stats group is untouched. Honours --dry-run.+func backfillBands(ctx context.Context, store *dynamo.DynamoStore, client dynamoAPI,+	opts backfillOpts, date string, datePlan plan.Plan, dayStart time.Time, res *backfillResult, ) error {+	// GET the daily-energy row first: the backfill must never create a phantom+	// row (Decision 7). An absent row is skipped and keeps the fallback — and+	// skipping before the readings query means a date with no row costs nothing+	// to reject.+	existing, err := store.GetDailyEnergy(ctx, opts.serial, date)+	if err != nil {+		return fmt.Errorf("get daily energy for bands (date=%s): %w", date, err)+	}+	if existing == nil {+		res.PeakSkippedAbsent+++		slog.Info("skip bands: flux-daily-energy row absent (no phantom-row creation)", "date", date)+		return nil+	}+ 	// Separate full-day readings query, kept independent of backfillOffpeak's-	// off-peak-window query on purpose: peak integrates the two windows-	// bracketing off-peak, so it needs the whole day. Off-peak recompute stays-	// byte-for-byte the original tool's behaviour (its own query, own write).+	// free-window query on purpose: the rated segments span the rest of the+	// day, so this needs all of it. The off-peak recompute keeps its own query+	// and its own write.+	dayEnd := dayStart.AddDate(0, 0, 1) 	readings, err := queryReadingsRange(ctx, client, opts.tableReadings, opts.serial, 		dayStart.Unix(), dayEnd.Unix()) 	if err != nil {-		return fmt.Errorf("query readings for peak (date=%s): %w", date, err)+		return fmt.Errorf("query readings for bands (date=%s): %w", date, err) 	} -	kwh, sampleCount, skippedPairs, ok := derivedstats.IntegratePeakGridImportKwh(-		toDerivedReadings(readings), dayStart.Unix(), offpeakStart.Unix(), offpeakEnd.Unix(), dayEnd.Unix())+	bands, totalKwh, ok := dynamo.IntegrateRatedBands(+		toDerivedReadings(readings), datePlan, dayStart, opts.location) 	if !ok { 		res.PeakSkippedSparse++-		slog.Info("skip peak: sparse readings in a bracketing window", "date", date, "readings", len(readings))+		slog.Info("skip bands: a rated segment failed the usability gate",+			"date", date, "readings", len(readings)) 		return nil 	} -	// GET the daily-energy row first: peak must never create a phantom row-	// (Decision 7). An absent row is skipped and keeps the iOS fallback.-	existing, err := store.GetDailyEnergy(ctx, opts.serial, date)-	if err != nil {-		return fmt.Errorf("get daily energy for peak (date=%s): %w", date, err)-	}-	if existing == nil {-		res.PeakSkippedAbsent++-		slog.Info("skip peak: flux-daily-energy row absent (no phantom-row creation)", "date", date)-		return nil-	}--	peakKwh := derivedstats.RoundEnergy(kwh)-	res.Summary = append(res.Summary, peakSummaryLine(date, existing.PeakGridImportKwh, peakKwh, sampleCount, skippedPairs))+	res.Summary = append(res.Summary, bandSummaryLine(date, existing.PeakGridImportKwh, totalKwh, bands))  	if opts.dryRun { 		res.PeakWritten++-		slog.Info("dry-run peak", "date", date, "peakGridImportKwh", peakKwh,-			"sampleCount", sampleCount, "skippedPairs", skippedPairs)+		slog.Info("dry-run bands", "date", date, "peakGridImportKwh", totalKwh, "bands", len(bands)) 		return nil 	} +	now := opts.now().UTC().Format(time.RFC3339) 	stats := dynamo.DerivedStats{-		PeakGridImportKwh: &peakKwh,-		PeakComputedAt:    opts.now().UTC().Format(time.RFC3339),+		PeakGridImportKwh: &totalKwh,+		PeakComputedAt:    now,+		BandImports:       bands,+		BandsComputedAt:   now, 	} 	if err := store.UpdateDailyEnergyDerived(ctx, opts.serial, date, stats); err != nil {-		return fmt.Errorf("write peak (date=%s): %w", date, err)+		return fmt.Errorf("write bands (date=%s): %w", date, err) 	} 	res.PeakWritten++-	slog.Info("wrote peak grid import", "date", date, "peakGridImportKwh", peakKwh,-		"sampleCount", sampleCount, "skippedPairs", skippedPairs)+	slog.Info("wrote band split and peak grid import",+		"date", date, "peakGridImportKwh", totalKwh, "bands", len(bands)) 	return nil } -// offpeakBoundaries returns the absolute Sydney-local times of the off-peak-// window on the given day. False indicates an unparseable window.-func offpeakBoundaries(day time.Time, loc *time.Location, start, end string) (time.Time, time.Time, bool) {-	startMin, endMin, ok := derivedstats.ParseOffpeakWindow(start, end)-	if !ok {-		return time.Time{}, time.Time{}, false-	}-	midnight := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)-	return midnight.Add(time.Duration(startMin) * time.Minute),-		midnight.Add(time.Duration(endMin) * time.Minute), true-}- // patchOffpeakRow returns a copy of stored with the five integration-sourced-// deltas, the three provenance fields, and status=complete. Every other-// field — the diagnostic startE*/endE* snapshots, socStart/socEnd,-// batteryDeltaPercent — is preserved verbatim per Decision 2.-func patchOffpeakRow(stored dynamo.OffpeakItem, deltas derivedstats.OffpeakDeltas, integratedAt time.Time) dynamo.OffpeakItem {+// deltas, the three provenance fields, the window geometry, and+// status=complete. Every other field — the diagnostic startE*/endE* snapshots,+// socStart/socEnd, batteryDeltaPercent — is preserved verbatim per Decision 2.+//+// Re-recording the geometry matters because the repair may run under a+// different window than the row was originally written with; leaving the old+// snapshot would mark a correctly-repaired row as stale (Q23).+func patchOffpeakRow(stored dynamo.OffpeakItem, deltas derivedstats.OffpeakDeltas, integratedAt time.Time, win offpeakWindow) dynamo.OffpeakItem { 	out := stored 	out.Status = dynamo.OffpeakStatusComplete 	out.GridUsageKwh = derivedstats.RoundEnergy(deltas.GridImportKwh)@@ -414,6 +477,8 @@ func patchOffpeakRow(stored dynamo.OffpeakItem, deltas derivedstats.OffpeakDelta 	out.IntegrationSampleCount = deltas.SampleCount 	out.IntegrationSkippedPairs = deltas.SkippedPairs 	out.IntegratedAt = integratedAt.Format(time.RFC3339)+	out.WindowStart = win.StartHHMM+	out.WindowEnd = win.EndHHMM 	return out } @@ -440,20 +505,22 @@ func summaryLine(date string, prev, next dynamo.OffpeakItem) string { 	) } -// peakSummaryLine formats a per-day peak line: the prior stored-// peakGridImportKwh (or "none" when the row had no peak yet), the newly-// integrated value, and the combined provenance counts across the two-// bracketing windows. Mirrors summaryLine's prev→new shape so the operator-// can scan peak alongside the off-peak deltas.-func peakSummaryLine(date string, prev *float64, next float64, sampleCount, skippedPairs int) string {-	prevStr := "none"+// bandSummaryLine formats a per-day band line: the prior stored+// peakGridImportKwh (or "none" when the row had none yet), the newly+// integrated total, and each rated band's geometry and energy. Mirrors+// summaryLine's prev→new shape so the operator can scan the rated side+// alongside the off-peak deltas and see which band moved.+func bandSummaryLine(date string, prev *float64, next float64, bands []dynamo.BandImportAttr) string {+	parts := make([]string, 0, len(bands))+	for _, b := range bands {+		parts = append(parts, fmt.Sprintf("%s-%s=%.2f", b.Start, b.End, b.Kwh))+	}+	detail := strings.Join(parts, " ") 	if prev != nil {-		prevStr = fmt.Sprintf("%.2f", *prev)-		return fmt.Sprintf("%s  peak %s→%.2f |Δ|=%.2f  samples=%d skipped=%d",-			date, prevStr, next, math.Abs(next-*prev), sampleCount, skippedPairs)+		return fmt.Sprintf("%s  peak %.2f→%.2f |Δ|=%.2f  bands %s",+			date, *prev, next, math.Abs(next-*prev), detail) 	}-	return fmt.Sprintf("%s  peak %s→%.2f  samples=%d skipped=%d",-		date, prevStr, next, sampleCount, skippedPairs)+	return fmt.Sprintf("%s  peak none→%.2f  bands %s", date, next, detail) }  // queryOffpeakRange paginates flux-offpeak for one serial and a closed date
cmd/backfill-grid/plan_test.go Added +310 / -0
diff --git a/cmd/backfill-grid/plan_test.go b/cmd/backfill-grid/plan_test.gonew file mode 100644index 0000000..ae4a4c2--- /dev/null+++ b/cmd/backfill-grid/plan_test.go@@ -0,0 +1,310 @@+package main++import (+	"context"+	"errors"+	"testing"+	"time"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++// These tests cover the switch from --offpeak-start/end flags to per-day plan+// resolution (Q24). A backfill spanning a plan switch needs a different window+// on either side of it; static flags would silently misattribute every day on+// the wrong side, which is the failure this replacement exists to prevent.++// gridReadingsForDay builds dense (10 s) readings across the whole Sydney-local+// day at a constant grid import, closing on the next local midnight so every+// band has a right bracket.+func gridReadingsForDay(t *testing.T, date string, loc *time.Location, watts float64) []dynamo.ReadingItem {+	t.Helper()+	day, err := time.ParseInLocation("2006-01-02", date, loc)+	require.NoError(t, err)+	end := day.AddDate(0, 0, 1)+	out := make([]dynamo.ReadingItem, 0, 24*60*60/10+1)+	for ts := day.Unix(); ts <= end.Unix(); ts += 10 {+		out = append(out, dynamo.ReadingItem{+			SysSn: testSerial, Timestamp: ts, Pgrid: watts, Soc: 50,+		})+	}+	return out+}++// decodeUpdatedBands pulls the bandImports payload back out of the captured+// UpdateItem so assertions run against what would actually be persisted.+func decodeUpdatedBands(t *testing.T, f *fakeDynamo) []dynamo.BandImportAttr {+	t.Helper()+	require.Len(t, f.updates, 1)+	av, ok := f.updates[0].ExpressionAttributeValues[":bi"]+	require.True(t, ok, "the band group must set :bi")+	var bands []dynamo.BandImportAttr+	require.NoError(t, attributevalue.Unmarshal(av, &bands))+	return bands+}++// A range spanning the switch date must repair each day under its own plan's+// window — the whole reason the flags were removed.+func TestBackfill_ResolvesWindowPerDayAcrossAPlanSwitch(t *testing.T) {+	loc := sydney(t)+	const before, after = "2026-07-31", "2026-08-01"+	f := &fakeDynamo{+		location: loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {+			existingCompleteRow(before), existingCompleteRow(after),+		}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			before: gridReadingsForDay(t, before, loc, 1000),+			after:  gridReadingsForDay(t, after, loc, 1000),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{+			before: existingDailyEnergyRow(before),+			after:  existingDailyEnergyRow(after),+		},+		pricingRows: []dynamo.PricingItem{+			testPricingRow("old", "2000-01-01", after, "11:00", "14:00"),+			testPricingRow("new", after, "", "10:00", "15:00"),+		},+	}+	opts := backfillOptsForTest(loc, before, after)++	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	require.Equal(t, 2, res.RowsWritten)++	geometry := map[string][2]string{}+	for _, put := range f.puts {+		row := decodeOffpeakItem(t, put.Item)+		geometry[row.Date] = [2]string{row.WindowStart, row.WindowEnd}+	}+	assert.Equal(t, [2]string{"11:00", "14:00"}, geometry[before],+		"switch eve is still priced by the predecessor")+	assert.Equal(t, [2]string{"10:00", "15:00"}, geometry[after],+		"the switch day belongs to the successor")+}++// The repaired row re-records the window it was integrated under, so a later+// plan edit shows up as a mismatch instead of silently repricing the day (Q23).+func TestBackfill_RewritesOffpeakWindowGeometry(t *testing.T) {+	loc := sydney(t)+	const date = "2026-08-03"+	// The stored row still carries the pre-switch geometry.+	stale := existingCompleteRow(date)+	stale.WindowStart = "11:00"+	stale.WindowEnd = "14:00"++	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {stale}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: gridReadingsForDay(t, date, loc, 1000),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{date: existingDailyEnergyRow(date)},+		pricingRows: []dynamo.PricingItem{+			testPricingRow("new", "2026-08-01", "", "10:00", "15:00"),+		},+	}+	opts := backfillOptsForTest(loc, date)++	_, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	require.Len(t, f.puts, 1)++	row := decodeOffpeakItem(t, f.puts[0].Item)+	assert.Equal(t, "10:00", row.WindowStart)+	assert.Equal(t, "15:00", row.WindowEnd)+	// 1 kW across the 5-hour free window.+	assert.InDelta(t, 5.0, row.GridUsageKwh, 0.05)+}++// The band split and its total land in the same write, and the total equals+// the sum of the bands — the two describe one physical quantity, so a screen+// showing either must show the same number.+func TestBackfill_WritesBandImportsWithPeakTotal(t *testing.T) {+	loc := sydney(t)+	const date = "2026-08-03"+	rate := 0.28+	savings := 0.35+	tou := dynamo.PricingItem{+		PricingID: "tou", StartDate: "2026-08-01", DefaultRate: 0.35, FeedInRate: 0.05,+		Windows: []dynamo.PricingWindow{+			{Start: "10:00", End: "15:00", Free: true},+			{Start: "01:00", End: "06:00", Rate: &rate},+		},+		SavingsReferenceRate: &savings,+	}+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: gridReadingsForDay(t, date, loc, 1000),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{date: existingDailyEnergyRow(date)},+		pricingRows:       []dynamo.PricingItem{tou},+	}+	opts := backfillOptsForTest(loc, date)++	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	assert.Equal(t, 1, res.PeakWritten)++	expr := *f.updates[0].UpdateExpression+	assert.Contains(t, expr, "bandImports")+	assert.Contains(t, expr, "bandsComputedAt")+	assert.Contains(t, expr, "peakGridImportKwh")+	assert.NotContains(t, expr, "derivedStatsComputedAt", "the derived-stats group stays untouched")++	assert.Equal(t, []dynamo.BandImportAttr{+		{Start: "00:00", End: "01:00", Kwh: 1.0},+		{Start: "01:00", End: "06:00", Kwh: 5.0},+		{Start: "06:00", End: "10:00", Kwh: 4.0},+		{Start: "15:00", End: "24:00", Kwh: 9.0},+	}, decodeUpdatedBands(t, f))++	var peak float64+	require.NoError(t, attributevalue.Unmarshal(f.updates[0].ExpressionAttributeValues[":pk"], &peak))+	assert.InDelta(t, 19.0, peak, 0.05, "24 h at 1 kW less the 5 h free window")+}++// Without a plan there is no window to integrate over and no segmentation to+// capture, so repairing the day would mean inventing its geometry.+func TestBackfill_NoPlanForDate_SkipsRowEntirely(t *testing.T) {+	loc := sydney(t)+	const date = "2026-05-18"+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: gridReadingsForDay(t, date, loc, 1000),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{date: existingDailyEnergyRow(date)},+		// The only plan starts well after the date being repaired.+		pricingRows: []dynamo.PricingItem{testPricingRow("later", "2026-08-01", "", "10:00", "15:00")},+	}+	opts := backfillOptsForTest(loc, date)++	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	assert.Equal(t, 1, res.RowsNoPlan)+	assert.Zero(t, res.RowsWritten)+	assert.Empty(t, f.puts, "no off-peak write without a window")+	assert.Empty(t, f.updates, "no band write without a segmentation")+}++// A plan with no free band has no off-peak window to recompute, but its rated+// bands still cover the whole day — so the band side must still run.+func TestBackfill_NoFreeBand_SkipsOffpeakButStillWritesBands(t *testing.T) {+	loc := sydney(t)+	const date = "2026-08-03"+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: gridReadingsForDay(t, date, loc, 1000),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{date: existingDailyEnergyRow(date)},+		pricingRows: []dynamo.PricingItem{{+			PricingID: "flat", StartDate: "2026-08-01", DefaultRate: 0.35, FeedInRate: 0.05,+		}},+	}+	opts := backfillOptsForTest(loc, date)++	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	assert.Equal(t, 1, res.RowsNoFreeBand)+	assert.Zero(t, res.RowsWritten)+	assert.Empty(t, f.puts, "no free window means no off-peak row to repair")++	assert.Equal(t, 1, res.PeakWritten)+	bands := decodeUpdatedBands(t, f)+	require.Len(t, bands, 1)+	assert.Equal(t, "00:00", bands[0].Start)+	assert.Equal(t, "24:00", bands[0].End)+	assert.InDelta(t, 24.0, bands[0].Kwh, 0.05)+}++// An unreadable pricing table is fatal rather than a per-row skip: continuing+// would repair every day in the range under no window at all.+func TestBackfill_PricingReadError_PropagatedAsFatal(t *testing.T) {+	loc := sydney(t)+	f := &fakeDynamo{+		location: loc,+		scanErr:  errors.New("pricing table unreachable"),+	}+	opts := backfillOptsForTest(loc, "2026-05-18")++	_, err := runBackfill(context.Background(), f, opts)++	require.Error(t, err)+	assert.Contains(t, err.Error(), "list pricing")+}++// AC 3.8: on a DST day the bands keep their wall-clock boundaries while the+// energies follow real elapsed time. Deriving boundaries by adding elapsed+// minutes to midnight would shift every band after the transition by an hour.+func TestBackfill_DSTDayBandsFollowWallClock(t *testing.T) {+	loc := sydney(t)+	const date = "2026-10-04" // DST start: 02:00 → 03:00, a 23-hour day+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: gridReadingsForDay(t, date, loc, 1000),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{date: existingDailyEnergyRow(date)},+		pricingRows: []dynamo.PricingItem{+			testPricingRow("new", "2026-01-01", "", "10:00", "15:00"),+		},+	}+	opts := backfillOptsForTest(loc, date)+	opts.now = func() time.Time { return time.Date(2026, 10, 6, 12, 0, 0, 0, loc) }++	_, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	bands := decodeUpdatedBands(t, f)+	require.Len(t, bands, 2)+	assert.Equal(t, "00:00", bands[0].Start)+	assert.Equal(t, "10:00", bands[0].End)+	// 00:00–10:00 local spans only nine real hours on this day.+	assert.InDelta(t, 9.0, bands[0].Kwh, 0.05)+	assert.InDelta(t, 9.0, bands[1].Kwh, 0.05, "15:00–24:00 is unaffected by the transition")+}++// The off-peak and band writes go to different tables in separate calls, so a+// re-run after an interruption must converge rather than double-apply.+func TestBackfill_RerunIsIdempotentAcrossBothWrites(t *testing.T) {+	loc := sydney(t)+	const date = "2026-08-03"+	newFake := func() *fakeDynamo {+		return &fakeDynamo{+			location:    loc,+			offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+			readingsByDate: map[string][]dynamo.ReadingItem{+				date: gridReadingsForDay(t, date, loc, 1000),+			},+			dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{date: existingDailyEnergyRow(date)},+			pricingRows: []dynamo.PricingItem{+				testPricingRow("new", "2026-08-01", "", "10:00", "15:00"),+			},+		}+	}+	opts := backfillOptsForTest(loc, date)++	first := newFake()+	_, err := runBackfill(context.Background(), first, opts)+	require.NoError(t, err)++	second := newFake()+	_, err = runBackfill(context.Background(), second, opts)+	require.NoError(t, err)++	assert.Equal(t, decodeOffpeakItem(t, first.puts[0].Item), decodeOffpeakItem(t, second.puts[0].Item))+	assert.Equal(t, decodeUpdatedBands(t, first), decodeUpdatedBands(t, second))+}
cmd/backfill-solar/main_test.go Modified +41 / -2
diff --git a/cmd/backfill-solar/main_test.go b/cmd/backfill-solar/main_test.goindex c7736e1..7993514 100644--- a/cmd/backfill-solar/main_test.go+++ b/cmd/backfill-solar/main_test.go@@ -21,6 +21,7 @@ const ( 	testSerial       = "TEST123" 	testEnergyTable  = "flux-daily-energy-test" 	testReadingTable = "flux-readings-test"+	testPricingTable = "flux-pricing-test" )  // fakeDynamo is a lightweight in-memory stand-in for the DynamoDB client.@@ -29,11 +30,50 @@ const ( type fakeDynamo struct { 	dailyEnergyRows  map[string][]dynamo.DailyEnergyItem // keyed by date or "*" for all 	readingsByDate   map[string][]dynamo.ReadingItem     // keyed by date "YYYY-MM-DD" Sydney+	pricingRows      []dynamo.PricingItem                // nil ⇒ the default 11:00–14:00 open-ended plan 	location         *time.Location 	queries          []*dynamodb.QueryInput 	updates          []*dynamodb.UpdateItemInput 	queryErrForTable map[string]error 	updateErr        error+	scanErr          error+}++// Scan serves the pricing read. A fixture that sets no pricingRows gets the+// pre-feature plan — free 11:00–14:00, one flat rate — which is the window+// every date in these tests was originally computed under.+func (f *fakeDynamo) Scan(_ context.Context, _ *dynamodb.ScanInput, _ ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) {+	if f.scanErr != nil {+		return nil, f.scanErr+	}+	rows := f.pricingRows+	if rows == nil {+		rows = []dynamo.PricingItem{testPricingRow("legacy-equivalent", "2000-01-01", "", "11:00", "14:00")}+	}+	avs := make([]map[string]types.AttributeValue, 0, len(rows))+	for i := range rows {+		av, err := attributevalue.MarshalMap(rows[i])+		if err != nil {+			return nil, err+		}+		avs = append(avs, av)+	}+	return &dynamodb.ScanOutput{Items: avs}, nil+}++// testPricingRow builds a band-shape plan whose free window is the given+// range and whose remainder carries a single flat rate.+func testPricingRow(id, startDate, endDate, freeStart, freeEnd string) dynamo.PricingItem {+	savings := 0.35+	item := dynamo.PricingItem{+		PricingID: id, StartDate: startDate, DefaultRate: 0.35, FeedInRate: 0.05,+		Windows:              []dynamo.PricingWindow{{Start: freeStart, End: freeEnd, Free: true}},+		SavingsReferenceRate: &savings,+	}+	if endDate != "" {+		item.EndDate = &endDate+	}+	return item }  func (f *fakeDynamo) Query(_ context.Context, params *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {@@ -190,10 +230,9 @@ func backfillOptsForTest(loc *time.Location) backfillOpts { 		serial:           testSerial, 		tableDailyEnergy: testEnergyTable, 		tableReadings:    testReadingTable,+		tablePricing:     testPricingTable, 		from:             "2026-04-14", 		to:               "2026-04-14",-		offpeakStart:     "11:00",-		offpeakEnd:       "14:00", 		location:         loc, 		now:              func() time.Time { return now }, 	}
cmd/backfill-solar/main.go Modified +50 / -9
diff --git a/cmd/backfill-solar/main.go b/cmd/backfill-solar/main.goindex 6cf5223..fc4541b 100644--- a/cmd/backfill-solar/main.go+++ b/cmd/backfill-solar/main.go@@ -7,14 +7,19 @@ // (totalKwh, start, end, boundarySource, percentOfDay, status, // averageKwhPerHour) is preserved byte-for-byte (Decision 7). //+// Each day's block layout is partitioned around the free window of the plan+// pricing that day, read from the pricing table rather than passed as flags: a+// range spanning a plan switch needs a different window on either side of it,+// and a static flag pair would recompute one side under the wrong layout (Q24).+// // Usage (with operator AWS credentials): // //	go run ./cmd/backfill-solar \ //	    --serial=AB1234 \ //	    --from=2026-04-09 --to=2026-05-08 \-//	    --offpeak-start=11:00 --offpeak-end=14:00 \ //	    --table-daily-energy=flux-daily-energy \ //	    --table-readings=flux-readings \+//	    --table-pricing=flux-pricing \ //	    [--dry-run] // // Defaults: from = today - 30d, to = yesterday (the practical readings TTL@@ -39,22 +44,25 @@ import (  	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" )  // dynamoAPI is the subset of the DynamoDB client this CLI uses. type dynamoAPI interface { 	Query(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) 	UpdateItem(ctx context.Context, params *dynamodb.UpdateItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error)+	// Scan serves the pricing read (ListPricingRows). The CLI never writes+	// pricing rows — the Lambda keeps sole write access to that table.+	Scan(ctx context.Context, params *dynamodb.ScanInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) }  type backfillOpts struct { 	serial           string 	tableDailyEnergy string 	tableReadings    string+	tablePricing     string 	from             string 	to               string-	offpeakStart     string-	offpeakEnd       string 	location         *time.Location 	dryRun           bool 	now              func() time.Time@@ -65,6 +73,7 @@ type backfillResult struct { 	RowsSkipped int 	RowsWritten int 	RowsDryRun  int+	RowsNoPlan  int // no plan prices the date — block layout unknowable 	IntentLog   []string } @@ -84,10 +93,9 @@ func main() { 	flag.StringVar(&opts.serial, "serial", os.Getenv("SYSTEM_SERIAL"), "AlphaESS system serial number (or env SYSTEM_SERIAL)") 	flag.StringVar(&opts.tableDailyEnergy, "table-daily-energy", os.Getenv("TABLE_DAILY_ENERGY"), "flux-daily-energy table name (or env TABLE_DAILY_ENERGY)") 	flag.StringVar(&opts.tableReadings, "table-readings", os.Getenv("TABLE_READINGS"), "flux-readings table name (or env TABLE_READINGS)")+	flag.StringVar(&opts.tablePricing, "table-pricing", os.Getenv("TABLE_PRICING"), "flux-pricing table name (or env TABLE_PRICING)") 	flag.StringVar(&opts.from, "from", defaultFrom, "start date inclusive (YYYY-MM-DD, Sydney TZ)") 	flag.StringVar(&opts.to, "to", defaultTo, "end date inclusive (YYYY-MM-DD, Sydney TZ)")-	flag.StringVar(&opts.offpeakStart, "offpeak-start", os.Getenv("OFFPEAK_START"), "off-peak window start HH:MM (or env OFFPEAK_START)")-	flag.StringVar(&opts.offpeakEnd, "offpeak-end", os.Getenv("OFFPEAK_END"), "off-peak window end HH:MM (or env OFFPEAK_END)") 	flag.BoolVar(&opts.dryRun, "dry-run", false, "log intended writes without invoking UpdateItem") 	flag.Parse() @@ -112,6 +120,7 @@ func main() { 	slog.Info("backfill complete", 		"scanned", res.RowsScanned, 		"skipped", res.RowsSkipped,+		"noPlan", res.RowsNoPlan, 		"written", res.RowsWritten, 		"dryRun", res.RowsDryRun, 	)@@ -127,8 +136,8 @@ func validateOpts(o backfillOpts) error { 	if o.tableReadings == "" { 		return fmt.Errorf("--table-readings is required") 	}-	if o.offpeakStart == "" || o.offpeakEnd == "" {-		return fmt.Errorf("--offpeak-start and --offpeak-end are required")+	if o.tablePricing == "" {+		return fmt.Errorf("--table-pricing is required") 	} 	from, err := time.ParseInLocation("2006-01-02", o.from, o.location) 	if err != nil {@@ -151,6 +160,12 @@ func validateOpts(o backfillOpts) error { // calling UpdateItem. func runBackfill(ctx context.Context, client dynamoAPI, opts backfillOpts) (*backfillResult, error) { 	res := &backfillResult{}+	pricingRows, err := dynamo.ListPricingRows(ctx, client, opts.tablePricing)+	if err != nil {+		return nil, fmt.Errorf("list pricing (%s): %w", opts.tablePricing, err)+	}+	plans := dynamo.PlansFromItems(pricingRows)+ 	rows, err := queryDailyEnergyRange(ctx, client, opts.tableDailyEnergy, opts.serial, opts.from, opts.to) 	if err != nil { 		return nil, fmt.Errorf("query daily energy (%s): %w", opts.tableDailyEnergy, err)@@ -169,6 +184,20 @@ func runBackfill(ctx context.Context, client dynamoAPI, opts backfillOpts) (*bac 			continue 		} +		// The block layout is partitioned around the day's free window, so+		// recomputing without knowing it would produce a different set of+		// blocks than the stored row has and the per-kind patch would land on+		// the wrong ones. A plan with no free band yields empty bounds, which+		// derivedstats.Blocks already reads as "no off-peak window" and which+		// is the layout that row was stored under anyway.+		datePlan, hasPlan := plan.PlanFor(plans, row.Date)+		if !hasPlan {+			res.RowsNoPlan+++			slog.Warn("skip: no plan prices this date", "date", row.Date)+			continue+		}+		offpeakStart, offpeakEnd := freeWindowHHMM(datePlan)+ 		dayStart, err := time.ParseInLocation("2006-01-02", row.Date, opts.location) 		if err != nil { 			res.RowsSkipped++@@ -191,8 +220,8 @@ func runBackfill(ctx context.Context, client dynamoAPI, opts backfillOpts) (*bac  		recomputed := derivedstats.Blocks( 			toDerivedReadings(readings),-			opts.offpeakStart,-			opts.offpeakEnd,+			offpeakStart,+			offpeakEnd, 			row.Date, 			row.Date, 			opts.now().In(opts.location),@@ -238,6 +267,18 @@ func runBackfill(ctx context.Context, client dynamoAPI, opts backfillOpts) (*bac 	return res, nil } +// freeWindowHHMM renders the plan's free band for the derivedstats helpers.+// A plan with no free band yields two empty strings, which those helpers+// already treat as "no off-peak window" and degrade accordingly — never a+// substituted default, which would invent a block boundary (AC 4.4).+func freeWindowHHMM(p plan.Plan) (start, end string) {+	startMin, endMin, ok := p.FreeWindowMinutes()+	if !ok {+		return "", ""+	}+	return plan.FormatBandTime(startMin), plan.FormatBandTime(endMin)+}+ // countDaylightPopulated reports how many daylight blocks (morning peak, // off-peak, afternoon peak) carry a non-nil SolarKwh, and the total number // of daylight blocks present on the row. Used to surface partial-backfill
cmd/backfill-solar/plan_test.go Added +140 / -0
diff --git a/cmd/backfill-solar/plan_test.go b/cmd/backfill-solar/plan_test.gonew file mode 100644index 0000000..cf72f48--- /dev/null+++ b/cmd/backfill-solar/plan_test.go@@ -0,0 +1,140 @@+package main++import (+	"context"+	"errors"+	"testing"+	"time"++	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"++	"github.com/ArjenSchwarz/flux/internal/derivedstats"+	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++// These tests cover the switch from --offpeak-start/end flags to per-day plan+// resolution (Q24). The block layout is partitioned around the day's free+// window, so recomputing a day under the wrong window produces a different set+// of blocks than the stored row has and the per-kind patch lands nowhere.++// A range spanning a plan switch must recompute each day under its own+// window — the whole reason the flags were removed.+func TestBackfill_ResolvesWindowPerDayAcrossAPlanSwitch(t *testing.T) {+	loc := sydney(t)+	const before, after = "2026-07-31", "2026-08-01"+	f := &fakeDynamo{+		location: loc,+		dailyEnergyRows: map[string][]dynamo.DailyEnergyItem{"*": {+			storedRowAllDaylightMissingSolar(before),+			storedRowAllDaylightMissingSolar(after),+		}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			before: minuteReadingsWithSolar(t, before, loc),+			after:  minuteReadingsWithSolar(t, after, loc),+		},+		pricingRows: []dynamo.PricingItem{+			testPricingRow("old", "2000-01-01", after, "11:00", "14:00"),+			testPricingRow("new", after, "", "10:00", "15:00"),+		},+	}+	opts := backfillOptsForTest(loc)+	opts.from, opts.to = before, after+	// Both fixture dates must be in the past for the in-progress clamp in+	// derivedstats.Blocks to stay inert.+	opts.now = func() time.Time { return time.Date(2026, 8, 5, 12, 0, 0, 0, loc) }++	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	require.Equal(t, 2, res.RowsWritten)+	require.Len(t, f.updates, 2)++	// The patched row keeps the stored block boundaries verbatim (Decision 7),+	// so the window each day was recomputed under shows up in the energy: the+	// off-peak block's solar is the integral over that day's free window, at a+	// constant 1.5 kW of generation.+	offpeakSolar := func(up int) float64 {+		patched := decodeDailyUsageFromUpdate(t, f.updates[up])+		require.NotNil(t, patched)+		for _, b := range patched.Blocks {+			if b.Kind == derivedstats.DailyUsageKindOffPeak {+				require.NotNil(t, b.SolarKwh)+				return *b.SolarKwh+			}+		}+		t.Fatalf("update %d has no off-peak block", up)+		return 0+	}++	assert.InDelta(t, 4.5, offpeakSolar(0), 0.05,+		"switch eve is still priced by the predecessor's 3-hour window")+	assert.InDelta(t, 7.5, offpeakSolar(1), 0.05,+		"the switch day belongs to the successor's 5-hour window")+}++// Without a plan the day's block boundaries are unknowable, and recomputing+// under a guessed window would patch the wrong blocks.+func TestBackfill_NoPlanForDate_SkipsRow(t *testing.T) {+	loc := sydney(t)+	f := &fakeDynamo{+		location:        loc,+		dailyEnergyRows: map[string][]dynamo.DailyEnergyItem{"*": {storedRowAllDaylightMissingSolar("2026-04-14")}},+		readingsByDate:  map[string][]dynamo.ReadingItem{"2026-04-14": minuteReadingsWithSolar(t, "2026-04-14", loc)},+		// The only plan starts well after the date being repaired.+		pricingRows: []dynamo.PricingItem{testPricingRow("later", "2026-08-01", "", "10:00", "15:00")},+	}+	opts := backfillOptsForTest(loc)++	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	assert.Equal(t, 1, res.RowsNoPlan)+	assert.Zero(t, res.RowsWritten)+	assert.Empty(t, f.updates)+}++// An unreadable pricing table is fatal rather than a per-row skip: continuing+// would recompute every day in the range under no window at all.+func TestBackfill_PricingReadError_PropagatedAsFatal(t *testing.T) {+	loc := sydney(t)+	f := &fakeDynamo{location: loc, scanErr: errors.New("pricing table unreachable")}++	_, err := runBackfill(context.Background(), f, backfillOptsForTest(loc))++	require.Error(t, err)+	assert.Contains(t, err.Error(), "list pricing")+}++// A plan with no free band carves out no off-peak block, which is the layout+// such a day's row was stored under — so the recompute must use empty bounds+// rather than substituting a window that isn't in the plan (AC 4.4).+func TestFreeWindowHHMM_NoFreeBandYieldsEmptyBounds(t *testing.T) {+	t.Parallel()+	flat := dynamo.PricingItem{+		PricingID: "flat", StartDate: "2026-08-01", DefaultRate: 0.35, FeedInRate: 0.05,+	}++	start, end := freeWindowHHMM(flat.Plan())++	assert.Empty(t, start)+	assert.Empty(t, end)+}++func TestFreeWindowHHMM_RendersThePlansFreeBand(t *testing.T) {+	t.Parallel()++	start, end := freeWindowHHMM(testPricingRow("p", "2026-08-01", "", "10:00", "15:00").Plan())++	assert.Equal(t, "10:00", start)+	assert.Equal(t, "15:00", end)+}++func TestValidateOpts_RejectsMissingPricingTable(t *testing.T) {+	loc := sydney(t)+	opts := backfillOptsForTest(loc)+	opts.tablePricing = ""++	err := validateOpts(opts)+	require.Error(t, err)+	assert.Contains(t, err.Error(), "table-pricing")+}
cmd/migrate-pricing/golden.go Added +176 / -0
diff --git a/cmd/migrate-pricing/golden.go b/cmd/migrate-pricing/golden.gonew file mode 100644index 0000000..94bb9a5--- /dev/null+++ b/cmd/migrate-pricing/golden.go@@ -0,0 +1,176 @@+package main++import (+	"context"+	"fmt"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan"+)++// dayEnergy is one retained day's stored energy, joined from the daily-energy+// row and its off-peak row. Pointer fields keep "never recorded" distinct from+// a measured zero — the distinction the legacy formula's table turns on.+type dayEnergy struct {+	Date              string+	EInput            *float64+	EOutput           *float64+	PeakGridImportKwh *float64+	Offpeak           *dynamo.OffpeakItem+	BandImports       []dynamo.BandImportAttr+}++// toDayEnergy converts to the domain cost input, band split included.+func (d dayEnergy) toDayEnergy() plan.DayEnergy {+	out := plan.DayEnergy{+		EInput:            d.EInput,+		EOutput:           d.EOutput,+		PeakGridImportKwh: d.PeakGridImportKwh,+	}+	if d.Offpeak != nil {+		row := d.Offpeak.PlanRow()+		out.Offpeak = &row+	}+	for _, b := range d.BandImports {+		out.BandImports = append(out.BandImports, plan.BandImport{Start: b.Start, End: b.End, Kwh: b.Kwh})+	}+	return out+}++// toTierTwoEnergy is toDayEnergy with the band split withheld, forcing the+// single-rate path.+//+// This is what the legacy formula is compared against, and the asymmetry is+// deliberate: the three-rate model has no notion of a per-band split, so+// including one would compare two different questions and flag the known ~1.5%+// gap between the stored peak figure and the summed bands (Q30) as a migration+// defect. The split's own accuracy is the poller's contract; what this check+// owns is whether the row conversion reprices a day.+func (d dayEnergy) toTierTwoEnergy() plan.DayEnergy {+	out := d.toDayEnergy()+	out.BandImports = nil+	return out+}++// legacyDayCosts is the pre-migration three-rate formula, written out in full+// rather than delegated: a golden check that calls the code under test proves+// nothing.+//+// It reproduces the behaviour exactly, including the two parts that a+// simplification would quietly drop — the preference for the server-computed+// peak figure over the eInput − off-peak residual (the two differ by ~1.5% by+// design, Q30), and the zero clamp on that residual.+func legacyDayCosts(row dynamo.LegacyPricingItem, day dayEnergy) plan.Costs {+	total := deref(day.EInput)+	rate := row.PeakRate++	var importCost, savings float64+	switch {+	case day.Offpeak != nil:+		off := day.Offpeak.GridUsageKwh+		peak := max(0, total-off)+		if day.PeakGridImportKwh != nil {+			peak = *day.PeakGridImportKwh+		}+		importCost = peak * rate+		savings = off * row.OffPeakSavingsRate+	case day.PeakGridImportKwh != nil:+		importCost = *day.PeakGridImportKwh * rate+	default:+		importCost = total * rate+	}++	feedIn := deref(day.EOutput) * row.FeedInRate+	return plan.Costs{+		ImportCost:   importCost,+		FeedInIncome: feedIn,+		Net:          importCost - feedIn,+		Savings:      savings,+	}+}++func deref(v *float64) float64 {+	if v == nil {+		return 0+	}+	return *v+}++// loadDays reads every retained daily-energy row for the serial and joins its+// off-peak row. Both tables are queried in full: the migration has to prove+// nothing changed for every day still on record, not a sampled range.+func loadDays(ctx context.Context, client dynamoAPI, opts migrateOpts) ([]dayEnergy, error) {+	energyRows, err := queryAll[dynamo.DailyEnergyItem](ctx, client, opts.tableDailyEnergy, opts.serial)+	if err != nil {+		return nil, fmt.Errorf("query daily energy (%s): %w", opts.tableDailyEnergy, err)+	}+	offpeakRows, err := queryAll[dynamo.OffpeakItem](ctx, client, opts.tableOffpeak, opts.serial)+	if err != nil {+		return nil, fmt.Errorf("query offpeak (%s): %w", opts.tableOffpeak, err)+	}++	byDate := make(map[string]dynamo.OffpeakItem, len(offpeakRows))+	for _, row := range offpeakRows {+		// A pending row carries no finalised deltas, so it cannot price a free+		// window — treat it as absent, exactly as the read endpoints do.+		if row.Status != dynamo.OffpeakStatusComplete {+			continue+		}+		byDate[row.Date] = row+	}++	out := make([]dayEnergy, 0, len(energyRows))+	for _, row := range energyRows {+		day := dayEnergy{+			Date:              row.Date,+			EInput:            ptr(row.EInput),+			EOutput:           ptr(row.EOutput),+			PeakGridImportKwh: row.PeakGridImportKwh,+			BandImports:       row.BandImports,+		}+		if op, ok := byDate[row.Date]; ok {+			day.Offpeak = &op+		}+		out = append(out, day)+	}+	return out, nil+}++func ptr(v float64) *float64 { return &v }++// queryAll pages one table for every row belonging to the serial.+func queryAll[T any](ctx context.Context, client dynamoAPI, table, serial string) ([]T, error) {+	keyCondition := "sysSn = :serial"+	forward := true+	input := &dynamodb.QueryInput{+		TableName:              &table,+		KeyConditionExpression: &keyCondition,+		ExpressionAttributeValues: map[string]types.AttributeValue{+			":serial": &types.AttributeValueMemberS{Value: serial},+		},+		ScanIndexForward: &forward,+	}+	var out []T+	for {+		page, err := client.Query(ctx, input)+		if err != nil {+			return nil, err+		}+		decoded := make([]T, len(page.Items))+		for i, av := range page.Items {+			if err := attributevalue.UnmarshalMap(av, &decoded[i]); err != nil {+				return nil, fmt.Errorf("unmarshal %s row: %w", table, err)+			}+		}+		out = append(out, decoded...)+		if page.LastEvaluatedKey == nil {+			break+		}+		input.ExclusiveStartKey = page.LastEvaluatedKey+	}+	return out, nil+}
cmd/migrate-pricing/main_test.go Added +499 / -0
diff --git a/cmd/migrate-pricing/main_test.go b/cmd/migrate-pricing/main_test.gonew file mode 100644index 0000000..e2180a1--- /dev/null+++ b/cmd/migrate-pricing/main_test.go@@ -0,0 +1,499 @@+package main++import (+	"context"+	"errors"+	"testing"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++const (+	testSerial       = "TEST123"+	testPricingTable = "flux-pricing-test"+	testEnergyTable  = "flux-daily-energy-test"+	testOffpeakTable = "flux-offpeak-test"+)++// fakeDynamo serves the three tables the migration reads and records every+// PutItem so tests can assert on what would be persisted.+type fakeDynamo struct {+	pricingItems []map[string]types.AttributeValue+	energyRows   []dynamo.DailyEnergyItem+	offpeakRows  []dynamo.OffpeakItem+	puts         []*dynamodb.PutItemInput+	scanErr      error+	queryErr     error+	putErr       error+}++func (f *fakeDynamo) Scan(_ context.Context, _ *dynamodb.ScanInput, _ ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) {+	if f.scanErr != nil {+		return nil, f.scanErr+	}+	return &dynamodb.ScanOutput{Items: f.pricingItems}, nil+}++func (f *fakeDynamo) Query(_ context.Context, params *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {+	if f.queryErr != nil {+		return nil, f.queryErr+	}+	switch *params.TableName {+	case testEnergyTable:+		return marshalItems(f.energyRows)+	case testOffpeakTable:+		return marshalItems(f.offpeakRows)+	}+	return &dynamodb.QueryOutput{}, nil+}++func (f *fakeDynamo) PutItem(_ context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+	f.puts = append(f.puts, params)+	if f.putErr != nil {+		return nil, f.putErr+	}+	return &dynamodb.PutItemOutput{}, nil+}++func marshalItems[T any](items []T) (*dynamodb.QueryOutput, error) {+	avs := make([]map[string]types.AttributeValue, 0, len(items))+	for i := range items {+		av, err := attributevalue.MarshalMap(items[i])+		if err != nil {+			return nil, err+		}+		avs = append(avs, av)+	}+	return &dynamodb.QueryOutput{Items: avs}, nil+}++func testOpts() migrateOpts {+	return migrateOpts{+		serial:           testSerial,+		tablePricing:     testPricingTable,+		tableDailyEnergy: testEnergyTable,+		tableOffpeak:     testOffpeakTable,+	}+}++// legacyRowAV marshals a legacy three-rate row the way the live table holds+// it — peakRate present, endDate inclusive.+func legacyRowAV(t *testing.T, id, start, end string, peak, feedIn, savings float64) map[string]types.AttributeValue {+	t.Helper()+	item := dynamo.LegacyPricingItem{+		PricingID: id, StartDate: start,+		PeakRate: peak, FeedInRate: feedIn, OffPeakSavingsRate: savings,+		CreatedAt: "2025-01-01T00:00:00Z", UpdatedAt: "2025-01-01T00:00:00Z",+	}+	if end != "" {+		item.EndDate = &end+	}+	av, err := attributevalue.MarshalMap(item)+	require.NoError(t, err)+	return av+}++// bandRowAV marshals an already-migrated band row.+func bandRowAV(t *testing.T, id, start, end string) map[string]types.AttributeValue {+	t.Helper()+	savings := 0.35+	item := dynamo.PricingItem{+		PricingID: id, StartDate: start, DefaultRate: 0.35, FeedInRate: 0.05,+		Windows:              []dynamo.PricingWindow{{Start: "11:00", End: "14:00", Free: true}},+		SavingsReferenceRate: &savings,+		CreatedAt:            "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z",+	}+	if end != "" {+		item.EndDate = &end+	}+	av, err := attributevalue.MarshalMap(item)+	require.NoError(t, err)+	return av+}++func sentinelAV(t *testing.T, openEndedID string) map[string]types.AttributeValue {+	t.Helper()+	av, err := attributevalue.MarshalMap(dynamo.PricingSentinel{+		PricingID: "__open_ended", OpenEndedID: &openEndedID, UpdatedAt: "2026-01-01T00:00:00Z",+	})+	require.NoError(t, err)+	return av+}++func energyRow(date string, eInput, eOutput float64, peak *float64) dynamo.DailyEnergyItem {+	return dynamo.DailyEnergyItem{+		SysSn: testSerial, Date: date, EInput: eInput, EOutput: eOutput,+		PeakGridImportKwh: peak,+	}+}++func offpeakRow(date string, gridUsage float64) dynamo.OffpeakItem {+	return dynamo.OffpeakItem{+		SysSn: testSerial, Date: date, Status: dynamo.OffpeakStatusComplete,+		GridUsageKwh: gridUsage,+		IntegratedAt: "2026-05-01T04:00:00Z", IntegrationSampleCount: 900,+		WindowStart: "11:00", WindowEnd: "14:00",+	}+}++func ptrF(v float64) *float64 { return &v }+func ptrS(v string) *string   { return &v }++// --- The golden check ---++// AC 5.2 across all four tier-2 input combinations: the day's cost must be+// bit-identical before and after the row conversion.+func TestMigration_CostsUnchangedForEveryTierTwoCombination(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			legacyRowAV(t, "p1", "2026-01-01", "", 0.35, 0.05, 0.32),+		},+		energyRows: []dynamo.DailyEnergyItem{+			energyRow("2026-05-01", 20, 8, ptrF(16.5)), // off-peak present, server peak present+			energyRow("2026-05-02", 20, 8, nil),        // off-peak present, server peak absent+			energyRow("2026-05-03", 20, 8, ptrF(16.5)), // off-peak absent, server peak present+			energyRow("2026-05-04", 20, 8, nil),        // both absent+		},+		offpeakRows: []dynamo.OffpeakItem{+			offpeakRow("2026-05-01", 3.5),+			offpeakRow("2026-05-02", 3.5),+		},+	}++	res, err := runMigration(context.Background(), f, testOpts())++	require.NoError(t, err)+	assert.Empty(t, res.Mismatches)+	assert.Equal(t, 4, res.DaysChecked)+	assert.Equal(t, 1, res.LegacyRows)+}++// The zero clamp on the eInput − off-peak residual: a day whose off-peak+// import exceeds its total must price at $0, not a negative amount.+func TestMigration_ZeroClampSurvivesTheTransform(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			legacyRowAV(t, "p1", "2026-01-01", "", 0.35, 0.05, 0.32),+		},+		energyRows:  []dynamo.DailyEnergyItem{energyRow("2026-05-01", 2, 0, nil)},+		offpeakRows: []dynamo.OffpeakItem{offpeakRow("2026-05-01", 5.0)},+	}++	res, err := runMigration(context.Background(), f, testOpts())++	require.NoError(t, err)+	assert.Empty(t, res.Mismatches)+	assert.Equal(t, 1, res.DaysChecked)+}++// The inclusive→exclusive end-date shift must gain and lose no day (AC 5.2):+// the predecessor's last priced day stays with the predecessor, and the+// successor's first day stays with the successor. Different rates on the two+// rows make a misattributed day show up as a cost mismatch.+func TestMigration_EndDateShiftPreservesDayOwnership(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			legacyRowAV(t, "old", "2026-01-01", "2026-05-31", 0.30, 0.05, 0.28),+			legacyRowAV(t, "new", "2026-06-01", "", 0.40, 0.06, 0.38),+		},+		energyRows: []dynamo.DailyEnergyItem{+			energyRow("2026-05-30", 20, 8, ptrF(16.5)),+			energyRow("2026-05-31", 20, 8, ptrF(16.5)), // predecessor's last day+			energyRow("2026-06-01", 20, 8, ptrF(16.5)), // successor's first day+		},+		offpeakRows: []dynamo.OffpeakItem{+			offpeakRow("2026-05-30", 3.5), offpeakRow("2026-05-31", 3.5), offpeakRow("2026-06-01", 3.5),+		},+	}++	res, err := runMigration(context.Background(), f, testOpts())++	require.NoError(t, err)+	assert.Empty(t, res.Mismatches)+	assert.Equal(t, 3, res.DaysChecked)++	// The closed row's exclusive end is the day after its inclusive one.+	var migrated dynamo.PricingItem+	require.NoError(t, attributevalue.UnmarshalMap(mustPut(t, f, "old").Item, &migrated))+	require.NotNil(t, migrated.EndDate)+	assert.Equal(t, "2026-06-01", *migrated.EndDate)+}++// A repriced day must be detected. There is no fixture that makes the real+// transform reprice a day — that is the property the tool exists to guarantee —+// so the detector is exercised directly against a post-migration plan set that+// prices the day at a different rate, which is what a broken transform would+// produce.+func TestCheckDay_RecordsAMismatchWhenTheDayReprices(t *testing.T) {+	legacy := []dynamo.LegacyPricingItem{{+		PricingID: "p1", StartDate: "2026-01-01",+		PeakRate: 0.30, FeedInRate: 0.05, OffPeakSavingsRate: 0.28,+	}}+	op := offpeakRow("2026-05-01", 3.5)+	day := dayEnergy{+		Date: "2026-05-01", EInput: ptrF(20), EOutput: ptrF(8),+		PeakGridImportKwh: ptrF(16.5), Offpeak: &op,+	}+	// The migrated row should carry rate 0.30; this one carries 0.40.+	wrong := dynamo.PlansFromItems([]dynamo.PricingItem{+		{+			PricingID: "p1", StartDate: "2026-01-01", DefaultRate: 0.40, FeedInRate: 0.05,+			Windows:              []dynamo.PricingWindow{{Start: "11:00", End: "14:00", Free: true}},+			SavingsReferenceRate: ptrF(0.28),+		},+	})++	res := &migrateResult{}+	checkDay(day, legacy, nil, wrong, res)++	assert.Equal(t, 1, res.DaysChecked)+	require.Len(t, res.Mismatches, 1)+	assert.Equal(t, "2026-05-01", res.Mismatches[0].Date)+	assert.NotEqual(t, res.Mismatches[0].Before.Net, res.Mismatches[0].After.Net)+}++// A day that loses its plan across the transform prices at zero, which the+// comparison reports rather than silently accepting — the shape a wrong+// end-date shift would take.+func TestCheckDay_DayLosingItsPlanIsAMismatch(t *testing.T) {+	legacy := []dynamo.LegacyPricingItem{{+		PricingID: "p1", StartDate: "2026-01-01", EndDate: ptrS("2026-05-31"),+		PeakRate: 0.30, FeedInRate: 0.05, OffPeakSavingsRate: 0.28,+	}}+	day := dayEnergy{Date: "2026-05-31", EInput: ptrF(20), EOutput: ptrF(8), PeakGridImportKwh: ptrF(16.5)}++	res := &migrateResult{}+	checkDay(day, legacy, nil, nil, res) // no post-migration plan covers the day++	require.Len(t, res.Mismatches, 1)+	assert.Zero(t, res.Mismatches[0].After.Net)+	assert.NotZero(t, res.Mismatches[0].Before.Net)+}++// The write gate: a failed check must abort before a single row is written,+// regardless of --apply.+func TestApplyMigration_MismatchAbortsBeforeAnyWrite(t *testing.T) {+	f := &fakeDynamo{}+	opts := testOpts()+	opts.apply = true+	res := &migrateResult{+		DaysChecked: 3,+		Mismatches:  []dayMismatch{{Date: "2026-05-01"}},+	}++	err := applyMigration(context.Background(), f, opts,+		[]dynamo.PricingItem{{PricingID: "p1", StartDate: "2026-01-01"}}, res)++	require.Error(t, err)+	assert.ErrorIs(t, err, ErrGoldenMismatch)+	assert.Empty(t, f.puts, "a failed check must write nothing")+	assert.Zero(t, res.RowsWritten)+	assert.Contains(t, res.Report[0], "MISMATCH 2026-05-01")+}++// --- Write behaviour ---++func TestMigration_DryRunIsTheDefaultAndWritesNothing(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			legacyRowAV(t, "p1", "2026-01-01", "", 0.35, 0.05, 0.32),+		},+		energyRows:  []dynamo.DailyEnergyItem{energyRow("2026-05-01", 20, 8, ptrF(16.5))},+		offpeakRows: []dynamo.OffpeakItem{offpeakRow("2026-05-01", 3.5)},+	}++	res, err := runMigration(context.Background(), f, testOpts())++	require.NoError(t, err)+	assert.Empty(t, f.puts)+	assert.Zero(t, res.RowsWritten)+	assert.NotEmpty(t, res.Report, "a dry run still reports what it would do")+}++// AC 5.1: the migrated row carries the free window its historical data was+// computed under, the former flat rate as the default, and the former off-peak+// savings rate — with its id preserved so the sentinel keeps pointing at it.+func TestMigration_ApplyWritesTheBandShapePreservingIDs(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			legacyRowAV(t, "p1", "2026-01-01", "", 0.35, 0.05, 0.32),+			sentinelAV(t, "p1"),+		},+		energyRows:  []dynamo.DailyEnergyItem{energyRow("2026-05-01", 20, 8, ptrF(16.5))},+		offpeakRows: []dynamo.OffpeakItem{offpeakRow("2026-05-01", 3.5)},+	}+	opts := testOpts()+	opts.apply = true++	res, err := runMigration(context.Background(), f, opts)++	require.NoError(t, err)+	assert.Equal(t, 1, res.RowsWritten)+	require.Len(t, f.puts, 1, "the sentinel row must not be rewritten")++	var got dynamo.PricingItem+	require.NoError(t, attributevalue.UnmarshalMap(f.puts[0].Item, &got))+	assert.Equal(t, "p1", got.PricingID)+	assert.Equal(t, 0.35, got.DefaultRate)+	assert.Equal(t, 0.05, got.FeedInRate)+	require.NotNil(t, got.SavingsReferenceRate)+	assert.Equal(t, 0.32, *got.SavingsReferenceRate)+	assert.Equal(t, []dynamo.PricingWindow{{Start: "11:00", End: "14:00", Free: true}}, got.Windows)+	assert.Nil(t, got.EndDate, "an open-ended row stays open-ended")+	assert.Equal(t, "2025-01-01T00:00:00Z", got.CreatedAt, "createdAt is carried across")++	// The written item must not carry the legacy marker, or a re-run would+	// treat it as unmigrated.+	assert.NotContains(t, f.puts[0].Item, "peakRate")+}++// --- Idempotence ---++func TestMigration_AlreadyMigratedTableIsANoOp(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			bandRowAV(t, "p1", "2026-01-01", ""),+			sentinelAV(t, "p1"),+		},+		energyRows:  []dynamo.DailyEnergyItem{energyRow("2026-05-01", 20, 8, ptrF(16.5))},+		offpeakRows: []dynamo.OffpeakItem{offpeakRow("2026-05-01", 3.5)},+	}+	opts := testOpts()+	opts.apply = true++	res, err := runMigration(context.Background(), f, opts)++	require.NoError(t, err)+	assert.Zero(t, res.LegacyRows)+	assert.Equal(t, 1, res.AlreadyBandRows)+	assert.Empty(t, f.puts)+	assert.Contains(t, res.Report[0], "already migrated")+}++// A table part-way through migration converts only what is left, and the days+// priced by the untouched band rows are verified band-vs-band and reported.+func TestMigration_MixedTableConvertsOnlyLegacyRows(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			legacyRowAV(t, "old", "2026-01-01", "2026-05-31", 0.30, 0.05, 0.28),+			bandRowAV(t, "new", "2026-06-01", ""),+		},+		energyRows: []dynamo.DailyEnergyItem{+			energyRow("2026-05-15", 20, 8, ptrF(16.5)),+			energyRow("2026-06-15", 20, 8, ptrF(16.5)),+		},+		offpeakRows: []dynamo.OffpeakItem{+			offpeakRow("2026-05-15", 3.5), offpeakRow("2026-06-15", 3.5),+		},+	}+	opts := testOpts()+	opts.apply = true++	res, err := runMigration(context.Background(), f, opts)++	require.NoError(t, err)+	assert.Equal(t, 1, res.LegacyRows)+	assert.Equal(t, 1, res.AlreadyBandRows)+	assert.Equal(t, 1, res.DaysChecked, "one day is priced by the legacy row")+	assert.Equal(t, 1, res.DaysBandChecked, "one day is priced by the already-band row")+	require.Len(t, f.puts, 1)++	var got dynamo.PricingItem+	require.NoError(t, attributevalue.UnmarshalMap(f.puts[0].Item, &got))+	assert.Equal(t, "old", got.PricingID)+}++// Days no row prices are counted, not compared: they showed no costs before+// the migration and must show none after (AC 2.7).+func TestMigration_UnpricedDaysAreReportedNotChecked(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			legacyRowAV(t, "p1", "2026-05-01", "", 0.35, 0.05, 0.32),+		},+		energyRows: []dynamo.DailyEnergyItem{+			energyRow("2026-04-01", 20, 8, ptrF(16.5)), // before any plan+			energyRow("2026-05-01", 20, 8, ptrF(16.5)),+		},+		offpeakRows: []dynamo.OffpeakItem{offpeakRow("2026-05-01", 3.5)},+	}++	res, err := runMigration(context.Background(), f, testOpts())++	require.NoError(t, err)+	assert.Equal(t, 1, res.DaysUnpriced)+	assert.Equal(t, 1, res.DaysChecked)+}++// --- Read failures ---++func TestMigration_PricingScanError_IsFatal(t *testing.T) {+	f := &fakeDynamo{scanErr: errors.New("pricing table unreachable")}++	_, err := runMigration(context.Background(), f, testOpts())++	require.Error(t, err)+	assert.Contains(t, err.Error(), "scan pricing")+}++func TestMigration_DailyEnergyQueryError_IsFatal(t *testing.T) {+	f := &fakeDynamo{+		pricingItems: []map[string]types.AttributeValue{+			legacyRowAV(t, "p1", "2026-01-01", "", 0.35, 0.05, 0.32),+		},+		queryErr: errors.New("throttled"),+	}++	_, err := runMigration(context.Background(), f, testOpts())++	require.Error(t, err)+	assert.Contains(t, err.Error(), "query daily energy")+}++// --- Options ---++func TestValidateOpts(t *testing.T) {+	t.Parallel()+	tests := map[string]func(*migrateOpts){+		"serial":       func(o *migrateOpts) { o.serial = "" },+		"pricing":      func(o *migrateOpts) { o.tablePricing = "" },+		"daily energy": func(o *migrateOpts) { o.tableDailyEnergy = "" },+		"offpeak":      func(o *migrateOpts) { o.tableOffpeak = "" },+	}+	for name, mutate := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			opts := testOpts()+			mutate(&opts)+			require.Error(t, validateOpts(opts))+		})+	}+	require.NoError(t, validateOpts(testOpts()))+}++// mustPut returns the PutItem whose row carries the given pricingId.+func mustPut(t *testing.T, f *fakeDynamo, id string) *dynamodb.PutItemInput {+	t.Helper()+	for _, p := range f.puts {+		if v, ok := p.Item["pricingId"].(*types.AttributeValueMemberS); ok && v.Value == id {+			return p+		}+	}+	// Dry-run fixtures have no puts; re-run with apply to inspect the payload.+	opts := testOpts()+	opts.apply = true+	_, err := runMigration(context.Background(), f, opts)+	require.NoError(t, err)+	for _, p := range f.puts {+		if v, ok := p.Item["pricingId"].(*types.AttributeValueMemberS); ok && v.Value == id {+			return p+		}+	}+	t.Fatalf("no PutItem for pricingId %q", id)+	return nil+}
cmd/migrate-pricing/main.go Added +416 / -0
diff --git a/cmd/migrate-pricing/main.go b/cmd/migrate-pricing/main.gonew file mode 100644index 0000000..8fbe9f6--- /dev/null+++ b/cmd/migrate-pricing/main.go@@ -0,0 +1,416 @@+// Package main is the one-shot migration CLI that converts the legacy+// three-rate pricing rows into the band model (Decision 3).+//+// The conversion itself is a handful of field moves, shared with the dynamo+// read path so the two can never disagree about what a migrated row looks+// like. The work here is the verification around it: AC 5.2 requires that+// every historical day's costs are identical before and after, and AC 5.3+// requires that to be checked against recorded pre-migration values before the+// legacy shape goes away.+//+// So the run is: read every pricing row and every retained daily-energy day,+// price each day under the pre-migration rules (inclusive end dates, the+// three-rate formula), transform the rows, price every day again under the+// band model, and diff. Any mismatch aborts before a single write — the tool+// is safe to run repeatedly, and refuses to be the thing that silently+// reprices history.+//+// The check is deliberately not "apply the same shared function twice": the+// golden side implements the legacy formula independently, including its+// server-peak preference and zero clamp (Q30), because a check that reuses the+// code under test proves nothing.+//+// Idempotent: a row with no peakRate attribute is already migrated and is+// skipped, so a second run is a no-op.+//+// Usage (with operator AWS credentials):+//+//	go run ./cmd/migrate-pricing \+//	    --serial=AB1234 \+//	    --table-pricing=flux-pricing \+//	    --table-daily-energy=flux-daily-energy \+//	    --table-offpeak=flux-offpeak \+//	    [--apply]+//+// Without --apply the tool reports what it would do and writes nothing.+package main++import (+	"context"+	"errors"+	"flag"+	"fmt"+	"log/slog"+	"math"+	"os"+	"sort"++	_ "time/tzdata"++	awsconfig "github.com/aws/aws-sdk-go-v2/config"+	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan"+)++// dynamoAPI is the subset of the DynamoDB client this CLI uses.+type dynamoAPI interface {+	Scan(ctx context.Context, params *dynamodb.ScanInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error)+	Query(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error)+	PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error)+}++// ErrGoldenMismatch is returned when at least one day prices differently+// before and after the transform. It is fatal by design: a migration that+// changes historical costs violates AC 5.2, and no partial write is preferable+// to a wrong one.+var ErrGoldenMismatch = errors.New("golden cost check failed: migration would change historical costs")++// costEpsilon is the tolerance for the before/after comparison. The two sides+// perform the same multiplications on the same inputs, so a real match is+// exact; the epsilon only absorbs float64 association differences.+const costEpsilon = 1e-9++type migrateOpts struct {+	serial           string+	tablePricing     string+	tableDailyEnergy string+	tableOffpeak     string+	apply            bool+}++// dayMismatch is one day whose costs changed across the transform.+type dayMismatch struct {+	Date          string+	Before, After plan.Costs+}++type migrateResult struct {+	LegacyRows      int // rows carrying peakRate — the ones to transform+	AlreadyBandRows int // rows already in the band shape — left alone+	DaysChecked     int // days priced by a legacy row, verified legacy-vs-band+	DaysBandChecked int // days priced by an already-band row, verified band-vs-band+	DaysUnpriced    int // days no row covers, before or after+	RowsWritten     int+	Mismatches      []dayMismatch+	Report          []string+}++func main() {+	slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))++	var opts migrateOpts+	flag.StringVar(&opts.serial, "serial", os.Getenv("SYSTEM_SERIAL"), "AlphaESS system serial number (or env SYSTEM_SERIAL)")+	flag.StringVar(&opts.tablePricing, "table-pricing", os.Getenv("TABLE_PRICING"), "flux-pricing table name (or env TABLE_PRICING)")+	flag.StringVar(&opts.tableDailyEnergy, "table-daily-energy", os.Getenv("TABLE_DAILY_ENERGY"), "flux-daily-energy table name (or env TABLE_DAILY_ENERGY)")+	flag.StringVar(&opts.tableOffpeak, "table-offpeak", os.Getenv("TABLE_OFFPEAK"), "flux-offpeak table name (or env TABLE_OFFPEAK)")+	flag.BoolVar(&opts.apply, "apply", false, "write the migrated rows (default: report only)")+	flag.Parse()++	if err := validateOpts(opts); err != nil {+		slog.Error("invalid options", "error", err)+		os.Exit(2)+	}++	ctx := context.Background()+	awsCfg, err := awsconfig.LoadDefaultConfig(ctx)+	if err != nil {+		slog.Error("load AWS config", "error", err)+		os.Exit(1)+	}++	res, err := runMigration(ctx, dynamodb.NewFromConfig(awsCfg), opts)+	if res != nil {+		for _, line := range res.Report {+			fmt.Println(line)+		}+	}+	if err != nil {+		slog.Error("migration aborted", "error", err)+		os.Exit(1)+	}+	slog.Info("migration complete",+		"legacyRows", res.LegacyRows,+		"alreadyBandRows", res.AlreadyBandRows,+		"daysChecked", res.DaysChecked,+		"daysBandChecked", res.DaysBandChecked,+		"daysUnpriced", res.DaysUnpriced,+		"rowsWritten", res.RowsWritten,+		"applied", opts.apply,+	)+	if !opts.apply {+		fmt.Println("\nDry run: nothing was written. Re-run with --apply to migrate.")+	}+}++func validateOpts(o migrateOpts) error {+	if o.serial == "" {+		return fmt.Errorf("--serial is required")+	}+	if o.tablePricing == "" {+		return fmt.Errorf("--table-pricing is required")+	}+	if o.tableDailyEnergy == "" {+		return fmt.Errorf("--table-daily-energy is required")+	}+	if o.tableOffpeak == "" {+		return fmt.Errorf("--table-offpeak is required")+	}+	return nil+}++// runMigration is the testable core. It never writes unless opts.apply is set+// and every day's costs survived the check.+func runMigration(ctx context.Context, client dynamoAPI, opts migrateOpts) (*migrateResult, error) {+	res := &migrateResult{}++	rows, err := scanPricingRaw(ctx, client, opts.tablePricing)+	if err != nil {+		return res, err+	}+	legacy, band, err := partitionRows(rows)+	if err != nil {+		return res, err+	}+	res.LegacyRows = len(legacy)+	res.AlreadyBandRows = len(band)++	if len(legacy) == 0 {+		res.Report = append(res.Report, "No legacy pricing rows found — already migrated.")+		return res, nil+	}++	// Transform first so the check can price each day both ways in one pass.+	// Nothing is written until the check has passed in full.+	migrated := make([]dynamo.PricingItem, 0, len(legacy))+	for _, row := range legacy {+		item, err := dynamo.TransformLegacyPricing(row)+		if err != nil {+			return res, fmt.Errorf("transform legacy row (pricingId=%s): %w", row.PricingID, err)+		}+		migrated = append(migrated, item)+		res.Report = append(res.Report, transformLine(row, item))+	}++	days, err := loadDays(ctx, client, opts)+	if err != nil {+		return res, err+	}++	// Post-migration plan set: the transformed rows plus the ones that were+	// already in the band shape and are not being touched.+	afterPlans := dynamo.PlansFromItems(append(append([]dynamo.PricingItem{}, migrated...), band...))++	for _, day := range days {+		checkDay(day, legacy, band, afterPlans, res)+	}++	return res, applyMigration(ctx, client, opts, migrated, res)+}++// applyMigration is the write gate. It is a separate step so the ordering+// invariant — no row is written until every day has been verified — is one+// readable guard rather than a property of where a loop happens to sit.+func applyMigration(ctx context.Context, client dynamoAPI, opts migrateOpts,+	migrated []dynamo.PricingItem, res *migrateResult,+) error {+	if err := res.goldenErr(); err != nil {+		for _, m := range res.Mismatches {+			res.Report = append(res.Report, mismatchLine(m))+		}+		return err+	}+	if !opts.apply {+		return nil+	}+	for _, item := range migrated {+		if err := putPricingRow(ctx, client, opts.tablePricing, item); err != nil {+			return err+		}+		res.RowsWritten+++	}+	return nil+}++// goldenErr returns the abort error when any day priced differently across the+// transform, and nil when every one matched.+func (r *migrateResult) goldenErr() error {+	if len(r.Mismatches) == 0 {+		return nil+	}+	return fmt.Errorf("%w: %d of %d days differ", ErrGoldenMismatch, len(r.Mismatches), r.DaysChecked)+}++// checkDay prices one day before and after the transform and records a+// mismatch if the two disagree.+//+// The two sides are deliberately asymmetric for a legacy-priced day: the+// "before" is the independent three-rate formula under inclusive end dates,+// the "after" is the band model under exclusive ones. That asymmetry is the+// point — it is what catches a wrong end-date shift or a mis-mapped rate,+// which comparing the shared transform against itself never would.+func checkDay(day dayEnergy, legacy []dynamo.LegacyPricingItem, band []dynamo.PricingItem,+	afterPlans []plan.Plan, res *migrateResult,+) {+	if row, ok := legacyRowFor(legacy, day.Date); ok {+		before := legacyDayCosts(row, day)+		after, _ := plan.DayCosts(planFor(afterPlans, day.Date), day.toTierTwoEnergy())+		res.DaysChecked+++		if !costsEqual(before, after) {+			res.Mismatches = append(res.Mismatches, dayMismatch{Date: day.Date, Before: before, After: after})+		}+		return+	}+	if item, ok := bandRowFor(band, day.Date); ok {+		// The row is not touched by the migration, so its days are verified+		// band-formula-vs-band-formula and reported for the operator's record+		// rather than treated as at-risk.+		costs, tier := plan.DayCosts(item.Plan(), day.toDayEnergy())+		res.DaysBandChecked+++		res.Report = append(res.Report,+			fmt.Sprintf("%s  already band-shape (plan=%s tier=%d) net=%.4f — unchanged by migration",+				day.Date, item.PricingID, tier, costs.Net))+		return+	}+	res.DaysUnpriced+++}++// costsEqual compares the four figures every screen shows.+func costsEqual(a, b plan.Costs) bool {+	return math.Abs(a.ImportCost-b.ImportCost) < costEpsilon &&+		math.Abs(a.FeedInIncome-b.FeedInIncome) < costEpsilon &&+		math.Abs(a.Net-b.Net) < costEpsilon &&+		math.Abs(a.Savings-b.Savings) < costEpsilon+}++// planFor returns the plan pricing date, or the zero plan when none does. A+// day that loses its plan across the transform produces zero costs here, which+// the comparison reports as a mismatch — exactly the off-by-one the exclusive+// end date could introduce.+func planFor(plans []plan.Plan, date string) plan.Plan {+	p, _ := plan.PlanFor(plans, date)+	return p+}++// legacyRowFor finds the legacy row pricing date under the pre-migration+// inclusive end-date rule.+func legacyRowFor(rows []dynamo.LegacyPricingItem, date string) (dynamo.LegacyPricingItem, bool) {+	for _, r := range rows {+		if date < r.StartDate {+			continue+		}+		if r.EndDate == nil || date <= *r.EndDate {+			return r, true+		}+	}+	return dynamo.LegacyPricingItem{}, false+}++// bandRowFor finds the already-band row pricing date under the exclusive+// end-date rule.+func bandRowFor(rows []dynamo.PricingItem, date string) (dynamo.PricingItem, bool) {+	for _, r := range rows {+		if r.Plan().Covers(date) {+			return r, true+		}+	}+	return dynamo.PricingItem{}, false+}++// transformLine reports one row's conversion so the operator can eyeball the+// end-date shift before applying it.+func transformLine(old dynamo.LegacyPricingItem, next dynamo.PricingItem) string {+	end := func(v *string) string {+		if v == nil {+			return "open"+		}+		return *v+	}+	return fmt.Sprintf("row %s  %s..%s (inclusive) → %s..%s (exclusive)  rate=%.4f feedIn=%.4f savings=%.4f  free 11:00-14:00",+		old.PricingID, old.StartDate, end(old.EndDate), next.StartDate, end(next.EndDate),+		next.DefaultRate, next.FeedInRate, deref(next.SavingsReferenceRate))+}++func mismatchLine(m dayMismatch) string {+	return fmt.Sprintf("MISMATCH %s  import %.4f→%.4f  feedIn %.4f→%.4f  net %.4f→%.4f  savings %.4f→%.4f",+		m.Date,+		m.Before.ImportCost, m.After.ImportCost,+		m.Before.FeedInIncome, m.After.FeedInIncome,+		m.Before.Net, m.After.Net,+		m.Before.Savings, m.After.Savings)+}++// putPricingRow writes the migrated row as a full item, preserving its+// pricingId. The sentinel row is never touched: it points at the open-ended+// row by id, and the transform does not change any id.+func putPricingRow(ctx context.Context, client dynamoAPI, table string, item dynamo.PricingItem) error {+	av, err := attributevalue.MarshalMap(item)+	if err != nil {+		return fmt.Errorf("marshal migrated row (pricingId=%s): %w", item.PricingID, err)+	}+	if _, err := client.PutItem(ctx, &dynamodb.PutItemInput{TableName: &table, Item: av}); err != nil {+		return fmt.Errorf("put migrated row (table=%s, pricingId=%s): %w", table, item.PricingID, err)+	}+	return nil+}++// scanPricingRaw pages the pricing table and returns the raw attribute maps,+// sentinel excluded. The raw form is required: attributevalue silently drops+// unknown attributes, so a legacy row decoded straight into PricingItem looks+// like a zero-rate band plan rather than anything recognisably legacy.+func scanPricingRaw(ctx context.Context, client dynamoAPI, table string) ([]map[string]types.AttributeValue, error) {+	var out []map[string]types.AttributeValue+	input := &dynamodb.ScanInput{TableName: &table}+	for {+		page, err := client.Scan(ctx, input)+		if err != nil {+			return nil, fmt.Errorf("scan pricing (table=%s): %w", table, err)+		}+		for _, av := range page.Items {+			if id, ok := av["pricingId"].(*types.AttributeValueMemberS); ok && id.Value == dynamo.PricingSentinelID {+				continue+			}+			out = append(out, av)+		}+		if page.LastEvaluatedKey == nil {+			break+		}+		input.ExclusiveStartKey = page.LastEvaluatedKey+	}+	return out, nil+}++// partitionRows splits the raw rows into the ones still carrying peakRate and+// the ones already in the band shape.+//+// An undecodable row aborts the run rather than being skipped. Skipping one+// would leave it untransformed AND drop every day it prices out of the golden+// check (those days fall through to "unpriced"), so --apply would migrate the+// remaining rows, report success, and leave a half-migrated table behind — the+// exact silent repricing this tool exists to prevent. dynamo.ListPricingRows+// errors on the same failure.+func partitionRows(rows []map[string]types.AttributeValue) ([]dynamo.LegacyPricingItem, []dynamo.PricingItem, error) {+	var legacy []dynamo.LegacyPricingItem+	var band []dynamo.PricingItem+	for _, av := range rows {+		if dynamo.IsLegacyPricingRow(av) {+			var item dynamo.LegacyPricingItem+			if err := attributevalue.UnmarshalMap(av, &item); err != nil {+				return nil, nil, fmt.Errorf("undecodable legacy pricing row: %w", err)+			}+			legacy = append(legacy, item)+			continue+		}+		var item dynamo.PricingItem+		if err := attributevalue.UnmarshalMap(av, &item); err != nil {+			return nil, nil, fmt.Errorf("undecodable pricing row: %w", err)+		}+		band = append(band, item)+	}+	sort.SliceStable(legacy, func(i, j int) bool { return legacy[i].StartDate < legacy[j].StartDate })+	sort.SliceStable(band, func(i, j int) bool { return band[i].StartDate < band[j].StartDate })+	return legacy, band, nil+}
cmd/poller/logging_test.go Modified +4 / -6
diff --git a/cmd/poller/logging_test.go b/cmd/poller/logging_test.goindex a9afc95..8eb9240 100644--- a/cmd/poller/logging_test.go+++ b/cmd/poller/logging_test.go@@ -31,12 +31,10 @@ func TestLogPollerStartupDoesNotLogAppSecret(t *testing.T) { 	var output bytes.Buffer 	logger := slog.New(slog.NewJSONHandler(&output, nil)) 	cfg := &config.Config{-		Serial:       "SYS-001",-		AppSecret:    "super-secret-value",-		OffpeakStart: 11 * time.Hour,-		OffpeakEnd:   14 * time.Hour,-		Location:     time.UTC,-		DryRun:       true,+		Serial:    "SYS-001",+		AppSecret: "super-secret-value",+		Location:  time.UTC,+		DryRun:    true, 	}  	logPollerStartup(cfg, logger)
cmd/poller/logging.go Modified +3 / -1
diff --git a/cmd/poller/logging.go b/cmd/poller/logging.goindex f4b406e..e442599 100644--- a/cmd/poller/logging.go+++ b/cmd/poller/logging.go@@ -35,9 +35,11 @@ func newJSONLogger(output io.Writer) *slog.Logger { // logPollerStartup logs selected non-secret configuration fields. Never logs // the full config to avoid leaking AppSecret. func logPollerStartup(cfg *config.Config, logger *slog.Logger) {+	// The off-peak window is no longer configuration, so it is no longer a+	// startup fact: each day's window is resolved from the plan pricing that+	// day and logged by the scheduler as it processes the day. 	logger.Info("poller starting", 		"serial", cfg.Serial,-		"offpeak", config.FormatHHMM(cfg.OffpeakStart)+"-"+config.FormatHHMM(cfg.OffpeakEnd), 		"tz", cfg.Location.String(), 		"dry_run", cfg.DryRun, 	)
cmd/poller/main.go Modified +22 / -10
diff --git a/cmd/poller/main.go b/cmd/poller/main.goindex 0236cb5..8e19b05 100644--- a/cmd/poller/main.go+++ b/cmd/poller/main.go@@ -39,8 +39,9 @@ func main() { 	// Create AlphaESS client. 	client := alphaess.NewClient(cfg.AppID, cfg.AppSecret, cfg.HTTPTimeout) -	// Create store (DynamoDB or dry-run logger).-	store, err := createStore(cfg)+	// Create store (DynamoDB or dry-run logger) and the read-only pricing+	// source the window-dependent jobs resolve their day's free band from.+	store, plans, err := createStore(cfg) 	if err != nil { 		slog.Error("create store failed", "error", err) 		os.Exit(1)@@ -53,7 +54,7 @@ func main() { 	defer cancel()  	// Run poller (blocks until ctx is cancelled).-	p := poller.New(client, store, cfg)+	p := poller.New(client, store, plans, cfg)  	// CloudWatch metrics for the daily-derived-stats summarisation pass. 	// Dry-run keeps the no-op variant set by poller.New.@@ -83,26 +84,37 @@ func main() { 	slog.Info("poller stopped") } -// createStore builds the appropriate Store implementation based on config.-func createStore(cfg *config.Config) (dynamo.Store, error) {+// createStore builds the Store and the read-only pricing source from config.+// Both come from the same DynamoDB client, so the pricing reader is created+// here rather than making the caller rebuild the AWS config.+func createStore(cfg *config.Config) (dynamo.Store, poller.PlanLister, error) { 	if cfg.DryRun {-		slog.Info("dry-run mode active, DynamoDB writes disabled")-		return dynamo.NewLogStore(slog.Default()), nil+		slog.Info("dry-run mode active, DynamoDB writes disabled; no pricing plans, so window-dependent jobs stay idle")+		return dynamo.NewLogStore(slog.Default()), noPlans{}, nil 	}  	awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), 		awsconfig.WithRegion(cfg.AWSRegion), 	) 	if err != nil {-		return nil, fmt.Errorf("load AWS config: %w", err)+		return nil, nil, fmt.Errorf("load AWS config: %w", err) 	}  	client := dynamodb.NewFromConfig(awsCfg)-	return dynamo.NewDynamoStore(client, dynamo.TableNames{+	store := dynamo.NewDynamoStore(client, dynamo.TableNames{ 		Readings:    cfg.TableReadings, 		DailyEnergy: cfg.TableDailyEnergy, 		DailyPower:  cfg.TableDailyPower, 		System:      cfg.TableSystem, 		Offpeak:     cfg.TableOffpeak,-	}), nil+	})+	return store, dynamo.NewDynamoPricingStore(client, cfg.TablePricing), nil }++// noPlans is the dry-run pricing source. Dry-run has no DynamoDB credentials+// or table names, so it reports an empty plan set — which the scheduler and+// the summarisation pass both treat as "no plan prices this day" and skip,+// exactly the no-write behaviour dry-run is for.+type noPlans struct{}++func (noPlans) ListPricing(context.Context) ([]dynamo.PricingItem, error) { return nil, nil }
docs/agent-notes/api-layer.md Modified +5 / -3
diff --git a/docs/agent-notes/api-layer.md b/docs/agent-notes/api-layer.mdindex 8023140..ebc5cab 100644--- a/docs/agent-notes/api-layer.md+++ b/docs/agent-notes/api-layer.md@@ -23,7 +23,8 @@  ## Handler -- `Handler` struct holds: `reader` (dynamo.Reader), `notes` (api.NoteWriter), `serial`, `apiToken`, `offpeakStart`, `offpeakEnd`, `nowFunc`.+- `Handler` struct holds: `reader` (dynamo.Reader), `notes` (api.NoteWriter), `serial`, `apiToken`, `nowFunc`, plus a pricing store injected via `SetPricingStore`.+- There are no `offpeakStart`/`offpeakEnd` fields and no `OFFPEAK_START`/`OFFPEAK_END` env vars: the free window is resolved per day from the plan pricing that day (`specs/time-of-use-pricing`, Decision 2). `/status`, `/day`, and `/history` each fetch plans once, inside their existing errgroup. A pricing read failure is a 500 — never a fabricated "no plan" (Q14). - `api.NoteWriter` is a small interface defined in the api package mirroring the dynamo-side `PutNote`/`DeleteNote` methods so handler tests can mock without importing dynamo internals (the parameter type is still `dynamo.NoteItem`). - `nowFunc` defaults to `time.Now`, overridable in tests for deterministic time. - `Handle` is the Lambda entry point — logs method, path, status, duration via slog. Never logs the token.@@ -39,7 +40,7 @@ - Phase 2: in-memory computation — extract latest reading, filter to 60s/15min subsets, compute pgridSustained, rolling averages, cutoff estimates, MinSOC for `low24h` (filtered to readings since 00:00 Sydney local on `now`'s date via `startOfDaySydney`; field name preserved for wire compatibility — see `specs/low-since-offpeak/decision_log.md` Decision 4). No off-peak dependency on this path. - `liveFresh` gate (T-1274): `resp.Live`, `battery.EstimatedCutoff`, and `rolling15min.EstimatedCutoff` are populated only when the most recent reading is within `liveDataStalenessThreshold` (90 s). Past that, the dashboard's existing "Awaiting live data" UI surfaces instead of presenting an aged reading as current. `low24h` and the rolling averages themselves still derive from their own time-windowed subsets, which already produce correct results when the latest reading is stale. - `filterReadings(readings, from, to)` — returns subset by timestamp range.-- `buildOffpeak(item, today, windowStart, windowEnd)` — always includes window times. Deltas come from a complete record's final values, or are projected from today's running `DailyEnergyItem` against the pending record's start snapshot. Pending without a daily-energy item leaves deltas null. The response carries a `status` field (`"pending"` or `"complete"`) so clients can mark in-progress data; `batteryDeltaPercent` is unavailable mid-window because no current SOC is computed in this slice.+- `buildOffpeak(item, readings, now, window)` — returns `nil` when the day has no free window (its plan has no free band, or no plan prices it), which serialises `/status.offpeak` as `null`; clients render that as "no window" and never substitute a default (Q35). Otherwise it includes the window times. Deltas come from a complete record's final values, or are live-integrated from readings while pending. Pending without usable readings leaves deltas null. The response carries a `status` field (`"pending"` or `"complete"`) so clients can mark in-progress data; `batteryDeltaPercent` is unavailable mid-window because no current SOC is computed in this slice. - `floatPtr(v)` — helper for nullable float64 fields. - Battery capacity: fallback 13.34 when system missing or cobat == 0. - Rolling 15min: requires >= 2 readings in window, otherwise null.@@ -70,7 +71,8 @@  - Package-level `sydneyTZ` var loaded once via init function — avoids repeated `time.LoadLocation` calls and silently discarded errors. Panics on load failure (fail-fast). - `computeCutoffTime(soc, pbat, capacityKwh, cutoffPercent, now)` — Linear extrapolation. Returns nil for charging/idle/SOC≤cutoff.-- `nextOffpeakStart(now, offpeakStart, offpeakEnd)` — Absolute Sydney-local time of the next off-peak window start (today's start if `now < todayEnd`, tomorrow's start otherwise). Returns `(_, false)` for invalid off-peak config. Used by `/status` to suppress cutoff predictions that land at or after the next scheduled charging window (see T-827).+- `nextOffpeakStart(now, plans)` — Absolute Sydney-local time of the next off-peak window start (today's start if `now < todayEnd`, tomorrow's start otherwise). The window comes from the plan pricing **the day the window falls on**, so on the eve of a plan switch it follows the successor's window rather than the outgoing one (AC 4.2/Q11). Returns `(_, false)` when that day has no free window. Used by `/status` to suppress cutoff predictions that land at or after the next scheduled charging window (see T-827).+- `offpeakWindow.bounds(local)` — resolves a window to absolute instants via `plan.SegmentBounds`, i.e. wall-clock `time.Date`, **not** midnight-plus-elapsed-minutes. The additive form is an hour off on Sydney's two DST-transition days and would put the free-window edge an hour away from the band edges served in the same payload. Pinned by `dst_window_test.go`. - `computeRollingAverages(readings)` — Mean of pload and pbat. Returns (0,0) for empty. - `computePgridSustained(readings)` — Iterates backwards from end, counts consecutive pgrid>500 within 30s gaps. Needs 3+ consecutive. Expects ascending order input. - `downsample(readings, date)` — 288 five-minute buckets, averages per bucket, omits empty. Uses `sydneyTZ`. Output is already in chronological order (buckets iterated 0..287).
docs/agent-notes/infrastructure.md Modified +9 / -2
diff --git a/docs/agent-notes/infrastructure.md b/docs/agent-notes/infrastructure.mdindex 1ca182a..93527cc 100644--- a/docs/agent-notes/infrastructure.md+++ b/docs/agent-notes/infrastructure.md@@ -8,10 +8,17 @@ ## Template Structure  The template is a skeleton with:-- 6 Parameters: ContainerImageUri, AlphaESSAppId, SystemSerialNumber, OffPeakWindowStart, OffPeakWindowEnd, SSMPathPrefix+- 4 Parameters: ContainerImageUri, AlphaESSAppId, SystemSerialNumber, SSMPathPrefix - 3 Outputs: FunctionUrl (from ApiFunctionUrl), EcsClusterName (from EcsCluster), EcsServiceName (from PollerService) - Resources section is empty — will be populated by subsequent tasks +`OffPeakWindowStart` / `OffPeakWindowEnd` were removed with the time-of-use+pricing feature: the free window is a property of the plan pricing each day+(that spec's Decision 2), so it switches with the plan rather than with a stack+update. Deploying that version leaves `/flux/offpeak-start` and+`/flux/offpeak-end` behind as orphaned SSM parameters — nothing reads them, and+CloudFormation no longer manages them.+ ## Linting  `cfn-lint` is available on this machine and should be used to validate template changes. Expected warnings for unused parameters and unresolved resource references are normal during incremental development.@@ -21,7 +28,7 @@ The template is a skeleton with: - **VPC/Networking**: Vpc, SubnetA/B, IGW, RouteTable, DynamoDB/S3 endpoints, SecurityGroup - **IAM**: TaskExecutionRole, TaskRole, LambdaExecutionRole - **CloudWatch**: PollerLogGroup, ApiLogGroup-- **SSM Parameters**: AppIdParameter, SerialParameter, OffpeakStartParameter, OffpeakEndParameter+- **SSM Parameters**: AppIdParameter, SerialParameter - **DynamoDB**: ReadingsTable, DailyEnergyTable, DailyPowerTable, SystemTable, OffpeakTable, NotesTable - **Lambda**: ApiFunction, ApiFunctionUrl, ApiFunctionUrlPermission - **ECS**: EcsCluster, TaskDefinition, PollerService
docs/architecture-diagrams.md Modified +17 / -6
diff --git a/docs/architecture-diagrams.md b/docs/architecture-diagrams.mdindex 47de9c5..ffa2f27 100644--- a/docs/architecture-diagrams.md+++ b/docs/architecture-diagrams.md@@ -149,7 +149,9 @@ the Internet Gateway. The VPC template also defines an S3 gateway endpoint, but the poller has no runtime S3 path — it is not drawn here to avoid implying one.  IAM is least-privilege per role: the poller's `TaskRole` reads SoC rules/devices-and writes readings, summaries, off-peak, and fire-state; the+and pricing (read-only — `Scan`/`GetItem`/`Query`, so it can resolve each day's+free window without gaining a write path) and writes readings, summaries,+off-peak, and fire-state; the `LambdaExecutionRole` is read-only on the energy tables and write-scoped to the user-authored tables (notes, devices, rules, pricing, presets) only. @@ -169,7 +171,7 @@ flowchart LR         g2["pollDailyPower<br/>every 1h · today + yesterday"]         g3["pollDailyEnergy<br/>every 1h · today + yesterday"]         g4["pollSystemInfo<br/>every 24h"]-        g5["offpeak scheduler<br/>at window start + end"]+        g5["offpeak scheduler<br/>wakes at local midnight<br/>resolves the day's free window"]         g6["pollDailySummary<br/>every 1h · derives, no API call"]         g7["midnightFinalizer<br/>~00:15 local"]     end@@ -211,13 +213,21 @@ Notes that matter for correctness: - **Today and yesterday both polled hourly** — yesterday is re-fetched so the   final pre-midnight snapshots land before the day rolls over. - **Off-peak energy is integrated from live readings** (post-T-1341) —-  `handleEnd` runs a strongly-consistent query of `flux-readings` over the-  configured window (e.g. 11:00–14:00) and sums the per-reading power deltas via+  `handleEnd` runs a strongly-consistent query of `flux-readings` over the free+  window and sums the per-reading power deltas via   `derivedstats.IntegrateOffpeakDeltas`, then writes the result to `flux-offpeak`.   The `getOneDateEnergy` snapshots captured at window start/end are retained for   diagnostics and drift logging only — they are not the basis of the computed   value. This is why `g5` reads from `flux-readings` rather than following the   `e3 → flux-daily-energy` path the other goroutines use.+- **The free window comes from the pricing plan, not configuration**+  (`specs/time-of-use-pricing`, Decision 2) — `g5` wakes at local midnight,+  refreshes its plan cache, and resolves that day's free band before sleeping to+  its start, so a plan switch moves the window with no stack update. A day whose+  plan has no free band sleeps through to the next midnight. Plan reads that+  fail are served from the last-good cache and never resolved as "no plan"+  (Q14). `g6` additionally captures the day's rated-band import split+  (`bandImports`) so banded costs outlive the 30-day readings TTL. - **`pollLiveData` also feeds SoC alerts** — the same 10s tick evaluates rules and   writes `flux-soc-fire-state`, so the poller touches more than the five tables   shown here. See diagram 8 for the full fire path.@@ -238,12 +248,12 @@ historical date. | `flux-daily-power` | `sysSn` / `uploadTime` | retained | Poller (hourly) | Lambda (Day Detail fallback) | | `flux-daily-energy` | `sysSn` / `date` | retained | Poller (hourly + summary + finalizer) | Lambda | | `flux-system` | `sysSn` | retained | Poller (24h) | Lambda |-| `flux-offpeak` | `sysSn` / `date` | retained | Poller (window diff) | Lambda |+| `flux-offpeak` | `sysSn` / `date` | retained | Poller (window integration, snapshots the window geometry) | Lambda | | `flux-notes` | `sysSn` / `date` | PITR | Lambda (`PUT /note`) | Lambda | | `flux-devices` | `deviceId` | PITR | Lambda + Poller (GC) | Poller (eval) | | `flux-soc-rules` | `deviceId` / `ruleId` | PITR | Lambda | Poller (eval) | | `flux-soc-fire-state` | `deviceRule` / `windowStartDate` | TTL 7d | Poller (idempotent) | Poller |-| `flux-pricing` | `pricingId` | PITR | Lambda | Lambda |+| `flux-pricing` | `pricingId` | PITR | Lambda | Lambda + Poller (read-only, for each day's free window) | | `flux-simulation-presets` | `presetId` | PITR | Lambda | Lambda |  Write ownership is deliberately split — the poller and the Lambda never share a@@ -270,6 +280,7 @@ flowchart LR      devices --> poller     rules --> poller+    pricing -->|free window per day| poller     poller -.->|GC delete| devices     poller -.->|GC delete| rules 
docs/flux-v1.md Modified +13 / -0
diff --git a/docs/flux-v1.md b/docs/flux-v1.mdindex 4e7fa6f..1f87d52 100644--- a/docs/flux-v1.md+++ b/docs/flux-v1.md@@ -99,6 +99,13 @@ The server rejects requests where the timestamp drifts more than 300 seconds fro  ### Off-Peak Calculation +> **Superseded twice.** The snapshot-diff basis was replaced by integration over+> `flux-readings` in T-1341 (`specs/offpeak-from-readings`); the snapshots are+> now retained for diagnostics only. The SSM-configured window was replaced by+> the free band of the pricing plan covering each day in T-1890/T-1891+> (`specs/time-of-use-pricing`, Decision 2). The description below is the+> original V1 plan, kept for context.+ The container is configured with the off-peak window (11:00 AM – 2:00 PM) via SSM Parameter Store. It calls `getOneDateEnergyBySn` at the start and end of the off-peak window and stores both snapshots. The diff between the two gives exact off-peak values for every energy metric:  - Off-peak grid usage = `eInput(end) - eInput(start)`@@ -390,6 +397,12 @@ The system serial number and off-peak window are configured server-side in SSM P  ### Off-Peak Window +> **Superseded by `specs/time-of-use-pricing` (Decision 2).** The window is now+> the free band of the pricing plan covering each day, so it changes with the+> plan rather than with a stack update, and the SSM parameters are gone.+> `/status.offpeak` is nullable — a day with no free band serialises `null`, and+> clients must render that as "no window" rather than substituting a default.+ Configured server-side in SSM Parameter Store (currently 11:00 AM – 2:00 PM). The container uses this to schedule energy snapshot calls and compute off-peak deltas. The app reads the window times from the `/status` response for display purposes only.  ### Load Alert Threshold
Flux/Flux/Charts/Expansion/ExpandedDayHost.swift Modified +12 / -1
diff --git a/Flux/Flux/Charts/Expansion/ExpandedDayHost.swift b/Flux/Flux/Charts/Expansion/ExpandedDayHost.swiftindex 83595a5..6cbd317 100644--- a/Flux/Flux/Charts/Expansion/ExpandedDayHost.swift+++ b/Flux/Flux/Charts/Expansion/ExpandedDayHost.swift@@ -5,11 +5,20 @@ struct ExpandedDayHostSnapshot {     var date: String     var readings: [ParsedReading]     var summary: DaySummary?+    /// The day's free window, from the plan pricing it, so the expanded chart+    /// shades the same band the inline one does.+    var offpeakWindow: PlanSegment? -    init(date: String = "", readings: [ParsedReading] = [], summary: DaySummary? = nil) {+    init(+        date: String = "",+        readings: [ParsedReading] = [],+        summary: DaySummary? = nil,+        offpeakWindow: PlanSegment? = nil+    ) {         self.date = date         self.readings = readings         self.summary = summary+        self.offpeakWindow = offpeakWindow     } } @@ -79,6 +88,7 @@ struct ExpandedDayHost: View {                 PowerChartView(                     date: controller.displayed.date,                     readings: controller.displayed.readings,+                    offpeakWindow: controller.displayed.offpeakWindow,                     selectedDate: $selectedDate                 )             case .dayBatteryCombined:@@ -86,6 +96,7 @@ struct ExpandedDayHost: View {                     date: controller.displayed.date,                     readings: controller.displayed.readings,                     summary: controller.displayed.summary,+                    offpeakWindow: controller.displayed.offpeakWindow,                     selectedDate: $selectedDate                 )             case .historySolar, .historyGridUsage, .historyDailyUsage:
Flux/Flux/DayDetail/BatteryCombinedChartView.swift Modified +10 / -2
diff --git a/Flux/Flux/DayDetail/BatteryCombinedChartView.swift b/Flux/Flux/DayDetail/BatteryCombinedChartView.swiftindex 3da4952..7fb76ca 100644--- a/Flux/Flux/DayDetail/BatteryCombinedChartView.swift+++ b/Flux/Flux/DayDetail/BatteryCombinedChartView.swift@@ -12,6 +12,8 @@ struct BatteryCombinedChartView: View {     let date: String     let readings: [ParsedReading]     let summary: DaySummary?+    /// The day's free window, from the plan pricing it; nil means no shading.+    let offpeakWindow: PlanSegment?     @Binding var selectedDate: Date?      var expansionScope: ChartScope {@@ -34,7 +36,7 @@ struct BatteryCombinedChartView: View {     @ViewBuilder     private var chartBody: some View {         Chart {-            if let offpeak = DayChartDomain.offpeakRange(for: date) {+            if let offpeak = DayChartDomain.offpeakRange(for: date, window: offpeakWindow) {                 RectangleMark(                     xStart: .value("Start", offpeak.start),                     xEnd: .value("End", offpeak.end)@@ -204,7 +206,13 @@ struct BatteryCombinedChartView: View {         guard let date = DateFormatting.parseTimestamp(reading.timestamp) else { return nil }         return ParsedReading(id: reading.id, date: date, point: reading)     }-    BatteryCombinedChartView(date: day.date, readings: parsed, summary: day.summary, selectedDate: .constant(nil))+    BatteryCombinedChartView(+        date: day.date,+        readings: parsed,+        summary: day.summary,+        offpeakWindow: PlanSegment(start: "11:00", end: "14:00", free: true, rate: 0),+        selectedDate: .constant(nil)+    )         .padding() } #endif
Flux/Flux/DayDetail/BatteryPowerChartView.swift Modified +9 / -3
diff --git a/Flux/Flux/DayDetail/BatteryPowerChartView.swift b/Flux/Flux/DayDetail/BatteryPowerChartView.swiftindex 61b1f2c..1822823 100644--- a/Flux/Flux/DayDetail/BatteryPowerChartView.swift+++ b/Flux/Flux/DayDetail/BatteryPowerChartView.swift@@ -5,6 +5,8 @@ import SwiftUI struct BatteryPowerChartView: View {     let date: String     let readings: [ParsedReading]+    /// The day's free window, from the plan pricing it; nil means no shading.+    let offpeakWindow: PlanSegment?      @State private var selectedDate: Date? @@ -25,7 +27,7 @@ struct BatteryPowerChartView: View {             }              Chart {-                if let offpeak = DayChartDomain.offpeakRange(for: date) {+                if let offpeak = DayChartDomain.offpeakRange(for: date, window: offpeakWindow) {                     RectangleMark(                         xStart: .value("Start", offpeak.start),                         xEnd: .value("End", offpeak.end)@@ -97,7 +99,11 @@ struct BatteryPowerChartView: View {         guard let date = DateFormatting.parseTimestamp(reading.timestamp) else { return nil }         return ParsedReading(id: reading.id, date: date, point: reading)     }-    BatteryPowerChartView(date: day.date, readings: parsed)-        .padding()+    BatteryPowerChartView(+        date: day.date,+        readings: parsed,+        offpeakWindow: PlanSegment(start: "11:00", end: "14:00", free: true, rate: 0)+    )+    .padding() } #endif
Flux/Flux/DayDetail/DayChartDomain.swift Modified +19 / -5
diff --git a/Flux/Flux/DayDetail/DayChartDomain.swift b/Flux/Flux/DayDetail/DayChartDomain.swiftindex cc28756..1a5901a 100644--- a/Flux/Flux/DayDetail/DayChartDomain.swift+++ b/Flux/Flux/DayDetail/DayChartDomain.swift@@ -13,14 +13,28 @@ enum DayChartDomain {         return startOfDay ... endOfDay     } -    static func offpeakRange(for dateString: String) -> (start: Date, end: Date)? {-        guard let startOfDay = DateFormatting.parseDayDate(dateString) else { return nil }-        let calendar = DateFormatting.sydneyCalendar+    /// The free-window shading band for a day's charts. The window comes from+    /// the free band of the plan pricing that day (AC 4.1) rather than a fixed+    /// 11:00–14:00, so it moves with the plan on the switch date. A day with no+    /// plan, or whose plan has no free band, gets no shading (AC 4.4).+    static func offpeakRange(for dateString: String, window: PlanSegment?) -> (start: Date, end: Date)? {+        guard let window,+              let startOfDay = DateFormatting.parseDayDate(dateString),+              let startMinutes = PlanWindow.parseBandTime(window.start),+              let endMinutes = PlanWindow.parseBandTime(window.end)+        else { return nil } -        guard let offpeakStart = calendar.date(byAdding: .hour, value: 11, to: startOfDay),-              let offpeakEnd = calendar.date(byAdding: .hour, value: 14, to: startOfDay)+        let calendar = DateFormatting.sydneyCalendar+        guard let offpeakStart = calendar.date(byAdding: .minute, value: startMinutes, to: startOfDay),+              let offpeakEnd = calendar.date(byAdding: .minute, value: endMinutes, to: startOfDay)         else { return nil }          return (offpeakStart, offpeakEnd)     }++    /// Convenience for the call sites that hold the plan list rather than an+    /// already-resolved window.+    static func offpeakRange(for dateString: String, plans: [PricingPlan]) -> (start: Date, end: Date)? {+        offpeakRange(for: dateString, window: PricingPlan.freeWindow(for: dateString, in: plans))+    } }
Flux/Flux/DayDetail/DayDetailView.swift Modified +6 / -0
diff --git a/Flux/Flux/DayDetail/DayDetailView.swift b/Flux/Flux/DayDetail/DayDetailView.swiftindex 34f6e1a..5b7f5ec 100644--- a/Flux/Flux/DayDetail/DayDetailView.swift+++ b/Flux/Flux/DayDetail/DayDetailView.swift@@ -224,10 +224,12 @@ struct DayDetailView: View {                 if viewModel.hasPowerData {                     DayDetailPanels.power(date: viewModel.date,                                           readings: viewModel.parsedReadings,+                                          offpeakWindow: viewModel.offpeakWindow,                                           selectedDate: $powerSelected)                     DayDetailPanels.battery(date: viewModel.date,                                             readings: viewModel.parsedReadings,                                             summary: viewModel.summary,+                                            offpeakWindow: viewModel.offpeakWindow,                                             selectedDate: $batterySelected)                 } else {                     DayDetailMessagePanel(title: "Power charts unavailable",@@ -235,6 +237,7 @@ struct DayDetailView: View {                     DayDetailPanels.battery(date: viewModel.date,                                             readings: viewModel.parsedReadings,                                             summary: viewModel.summary,+                                            offpeakWindow: viewModel.offpeakWindow,                                             selectedDate: $batterySelected)                 }             } else if let error = viewModel.error {@@ -319,10 +322,12 @@ struct DayDetailView: View {             if viewModel.hasPowerData {                 DayDetailPanels.power(date: viewModel.date,                                       readings: viewModel.parsedReadings,+                                      offpeakWindow: viewModel.offpeakWindow,                                       selectedDate: $powerSelected)                 DayDetailPanels.battery(date: viewModel.date,                                         readings: viewModel.parsedReadings,                                         summary: viewModel.summary,+                                        offpeakWindow: viewModel.offpeakWindow,                                         selectedDate: $batterySelected)             } else {                 DayDetailMessagePanel(title: "Power charts unavailable",@@ -330,6 +335,7 @@ struct DayDetailView: View {                 DayDetailPanels.battery(date: viewModel.date,                                         readings: viewModel.parsedReadings,                                         summary: viewModel.summary,+                                        offpeakWindow: viewModel.offpeakWindow,                                         selectedDate: $batterySelected)             }         } else if let error = viewModel.error {
Flux/Flux/DayDetail/DayDetailViewModel.swift Modified +16 / -4
diff --git a/Flux/Flux/DayDetail/DayDetailViewModel.swift b/Flux/Flux/DayDetail/DayDetailViewModel.swiftindex 7d83997..726d9ca 100644--- a/Flux/Flux/DayDetail/DayDetailViewModel.swift+++ b/Flux/Flux/DayDetail/DayDetailViewModel.swift@@ -105,11 +105,19 @@ final class DayDetailViewModel {         await loadDay()     } -    /// Costs for the viewed day. Returns nil when no pricing period covers-    /// the day or the daily summary is missing (AC 4.6).+    /// Costs for the viewed day. Returns nil when no plan covers the day or+    /// the daily summary is missing (AC 2.7).     var costs: DayCosts? {         guard let summary else { return nil }-        return summary.costs(forDate: date, in: pricingService.periods)+        return summary.costs(forDate: date, in: pricingService.plans)+    }++    /// The free window of the plan pricing the viewed day, used for chart+    /// shading and the off-peak reading stats. Nil on a day with no plan or no+    /// free band — those get no window rather than a substituted default+    /// (AC 4.4).+    var offpeakWindow: PlanSegment? {+        PricingPlan.freeWindow(for: date, in: pricingService.plans)     }      /// AC 2.7 requires a refetch on every Day Detail open. Called from the@@ -133,7 +141,11 @@ final class DayDetailViewModel {             peakPeriods = response.peakPeriods ?? []             dailyUsage = response.dailyUsage             note = response.note-            offpeakStats = OffpeakReadingStats.compute(date: date, readings: parsedReadings)+            offpeakStats = OffpeakReadingStats.compute(+                date: date,+                readings: parsedReadings,+                offpeakWindow: offpeakWindow+            )             error = nil         } catch {             readings = []
Flux/Flux/DayDetail/DayDetailViewSupport.swift Modified +15 / -2
diff --git a/Flux/Flux/DayDetail/DayDetailViewSupport.swift b/Flux/Flux/DayDetail/DayDetailViewSupport.swiftindex e70fc83..f71589a 100644--- a/Flux/Flux/DayDetail/DayDetailViewSupport.swift+++ b/Flux/Flux/DayDetail/DayDetailViewSupport.swift@@ -5,12 +5,18 @@ enum DayDetailPanels {     static func power(         date: String,         readings: [ParsedReading],+        offpeakWindow: PlanSegment?,         selectedDate: Binding<Date?>     ) -> some View {         FluxPanel {             VStack(alignment: .leading, spacing: 0) {                 FluxPanelHeader(label: "Power", right: "kW")-                PowerChartView(date: date, readings: readings, selectedDate: selectedDate)+                PowerChartView(+                    date: date,+                    readings: readings,+                    offpeakWindow: offpeakWindow,+                    selectedDate: selectedDate+                )                 HStack(spacing: 14) {                     legendChip(color: FluxTheme.Palette.amber, text: "Solar")                     legendChip(color: FluxTheme.Palette.load, text: "House")@@ -27,12 +33,19 @@ enum DayDetailPanels {         date: String,         readings: [ParsedReading],         summary: DaySummary?,+        offpeakWindow: PlanSegment?,         selectedDate: Binding<Date?>     ) -> some View {         FluxPanel {             VStack(alignment: .leading, spacing: 0) {                 FluxPanelHeader(label: "Battery", right: "% · ± kW")-                BatteryCombinedChartView(date: date, readings: readings, summary: summary, selectedDate: selectedDate)+                BatteryCombinedChartView(+                    date: date,+                    readings: readings,+                    summary: summary,+                    offpeakWindow: offpeakWindow,+                    selectedDate: selectedDate+                )             }         }     }
Flux/Flux/DayDetail/PowerChartView.swift Modified +9 / -2
diff --git a/Flux/Flux/DayDetail/PowerChartView.swift b/Flux/Flux/DayDetail/PowerChartView.swiftindex 710b29b..0613c80 100644--- a/Flux/Flux/DayDetail/PowerChartView.swift+++ b/Flux/Flux/DayDetail/PowerChartView.swift@@ -7,6 +7,8 @@ struct PowerChartView: View {      let date: String     let readings: [ParsedReading]+    /// The day's free window, from the plan pricing it; nil means no shading.+    let offpeakWindow: PlanSegment?     @Binding var selectedDate: Date?      var expansionScope: ChartScope {@@ -40,7 +42,7 @@ struct PowerChartView: View {     @ViewBuilder     private var chartBody: some View {         Chart {-            if let offpeak = DayChartDomain.offpeakRange(for: date) {+            if let offpeak = DayChartDomain.offpeakRange(for: date, window: offpeakWindow) {                 RectangleMark(                     xStart: .value("Start", offpeak.start),                     xEnd: .value("End", offpeak.end)@@ -125,7 +127,12 @@ struct PowerChartView: View {         guard let date = DateFormatting.parseTimestamp(reading.timestamp) else { return nil }         return ParsedReading(id: reading.id, date: date, point: reading)     }-    PowerChartView(date: day.date, readings: parsed, selectedDate: .constant(nil))+    PowerChartView(+        date: day.date,+        readings: parsed,+        offpeakWindow: PlanSegment(start: "11:00", end: "14:00", free: true, rate: 0),+        selectedDate: .constant(nil)+    )         .padding() } #endif
Flux/Flux/DayDetail/SOCChartView.swift Modified +9 / -2
diff --git a/Flux/Flux/DayDetail/SOCChartView.swift b/Flux/Flux/DayDetail/SOCChartView.swiftindex 6548ea6..2d9f301 100644--- a/Flux/Flux/DayDetail/SOCChartView.swift+++ b/Flux/Flux/DayDetail/SOCChartView.swift@@ -6,6 +6,8 @@ struct SOCChartView: View {     let date: String     let readings: [ParsedReading]     let summary: DaySummary?+    /// The day's free window, from the plan pricing it; nil means no shading.+    let offpeakWindow: PlanSegment?      @State private var selectedDate: Date? @@ -18,7 +20,7 @@ struct SOCChartView: View {             }              Chart {-                if let offpeak = DayChartDomain.offpeakRange(for: date) {+                if let offpeak = DayChartDomain.offpeakRange(for: date, window: offpeakWindow) {                     RectangleMark(                         xStart: .value("Start", offpeak.start),                         xEnd: .value("End", offpeak.end)@@ -98,7 +100,12 @@ struct SOCChartView: View {         guard let date = DateFormatting.parseTimestamp(reading.timestamp) else { return nil }         return ParsedReading(id: reading.id, date: date, point: reading)     }-    SOCChartView(date: day.date, readings: parsed, summary: day.summary)+    SOCChartView(+        date: day.date,+        readings: parsed,+        summary: day.summary,+        offpeakWindow: PlanSegment(start: "11:00", end: "14:00", free: true, rate: 0)+    )         .padding() } #endif
Flux/Flux/Helpers/OffpeakReadingStats.swift Modified +6 / -2
diff --git a/Flux/Flux/Helpers/OffpeakReadingStats.swift b/Flux/Flux/Helpers/OffpeakReadingStats.swiftindex bec0aad..790eb4f 100644--- a/Flux/Flux/Helpers/OffpeakReadingStats.swift+++ b/Flux/Flux/Helpers/OffpeakReadingStats.swift@@ -23,12 +23,16 @@ struct OffpeakReadingStats: Equatable {     /// window-specific stats. The API's `OffpeakData.gridUsageKwh` is the     /// source of truth when available; the integrated value here is a     /// fallback for the Day Detail summary split.-    static func compute(date: String, readings: [ParsedReading]) -> OffpeakReadingStats {+    static func compute(+        date: String,+        readings: [ParsedReading],+        offpeakWindow: PlanSegment?+    ) -> OffpeakReadingStats {         guard !readings.isEmpty else { return .empty }          let lowest = readings.min { $0.point.soc < $1.point.soc } -        guard let range = DayChartDomain.offpeakRange(for: date) else {+        guard let range = DayChartDomain.offpeakRange(for: date, window: offpeakWindow) else {             return OffpeakReadingStats(                 lowestSOC: lowest?.point.soc,                 lowestSOCTimestamp: lowest?.date,
Flux/Flux/History/HistoryViewModel.swift Modified +9 / -3
diff --git a/Flux/Flux/History/HistoryViewModel.swift b/Flux/Flux/History/HistoryViewModel.swiftindex dd8f4ef..a5a67d2 100644--- a/Flux/Flux/History/HistoryViewModel.swift+++ b/Flux/Flux/History/HistoryViewModel.swift@@ -73,10 +73,11 @@ final class HistoryViewModel {     }      /// Costs for the currently-loaded range. Computed lazily — recomputes-    /// whenever the underlying `days` or `pricingService.periods` change,-    /// thanks to `@Observable` tracking on both.+    /// whenever the underlying `days` or `pricingService.plans` change, thanks+    /// to `@Observable` tracking on both. Each day is priced by the plan+    /// covering it, so a range spanning a switch date sums both sides.     var periodCosts: PeriodCosts? {-        PeriodCosts.compute(days: days, pricing: pricingService.periods)+        PeriodCosts.compute(days: days, pricing: pricingService.plans)     }      /// AC 2.7 requires a refetch on every History range change. Called from@@ -279,6 +280,11 @@ final class HistoryViewModel {                 cached.offpeakGridImportKwh = day.offpeakGridImportKwh                 cached.offpeakGridExportKwh = day.offpeakGridExportKwh                 cached.peakGridImportKwh = day.peakGridImportKwh+                cached.bandImports = day.bandImports+                cached.offpeakWindowStart = day.offpeakWindowStart+                cached.offpeakWindowEnd = day.offpeakWindowEnd+                cached.offpeakIntegratedAt = day.offpeakIntegratedAt+                cached.offpeakSampleCount = day.offpeakSampleCount                 cached.note = day.note                 warnIfClearing(cached: cached, day: day)                 cached.dailyUsage = day.dailyUsage
Flux/Flux/Models/CachedDayEnergy.swift Modified +21 / -0
diff --git a/Flux/Flux/Models/CachedDayEnergy.swift b/Flux/Flux/Models/CachedDayEnergy.swiftindex f3572ba..f093b99 100644--- a/Flux/Flux/Models/CachedDayEnergy.swift+++ b/Flux/Flux/Models/CachedDayEnergy.swift@@ -14,6 +14,17 @@ final class CachedDayEnergy {     var peakGridImportKwh: Double?     var note: String? +    // The rated-band split and the off-peak row's geometry and provenance.+    // Cached alongside the energy values because History serves cached days+    // when a fetch fails: without them the same day would reprice at the+    // fallback tier offline and at the banded tier online, and the two screens+    // would disagree (Data Consistency).+    var bandImports: [BandImport]?+    var offpeakWindowStart: String?+    var offpeakWindowEnd: String?+    var offpeakIntegratedAt: String?+    var offpeakSampleCount: Int?+     // Derived stats persisted as optional Codable values (per     // daily-derived-stats AC 5.4). SwiftData stores Codable structs as     // transformable blobs without needing an extra @Relationship — keeping@@ -34,6 +45,11 @@ final class CachedDayEnergy {         offpeakGridImportKwh = dayEnergy.offpeakGridImportKwh         offpeakGridExportKwh = dayEnergy.offpeakGridExportKwh         peakGridImportKwh = dayEnergy.peakGridImportKwh+        bandImports = dayEnergy.bandImports+        offpeakWindowStart = dayEnergy.offpeakWindowStart+        offpeakWindowEnd = dayEnergy.offpeakWindowEnd+        offpeakIntegratedAt = dayEnergy.offpeakIntegratedAt+        offpeakSampleCount = dayEnergy.offpeakSampleCount         note = dayEnergy.note         dailyUsage = dayEnergy.dailyUsage         socLow = dayEnergy.socLow@@ -52,6 +68,11 @@ final class CachedDayEnergy {             offpeakGridImportKwh: offpeakGridImportKwh,             offpeakGridExportKwh: offpeakGridExportKwh,             peakGridImportKwh: peakGridImportKwh,+            bandImports: bandImports,+            offpeakWindowStart: offpeakWindowStart,+            offpeakWindowEnd: offpeakWindowEnd,+            offpeakIntegratedAt: offpeakIntegratedAt,+            offpeakSampleCount: offpeakSampleCount,             note: note,             dailyUsage: dailyUsage,             socLow: socLow,
Flux/Flux/Settings/Pricing/PricingEditor.swift Modified +191 / -75
diff --git a/Flux/Flux/Settings/Pricing/PricingEditor.swift b/Flux/Flux/Settings/Pricing/PricingEditor.swiftindex 7a69cba..546fe87 100644--- a/Flux/Flux/Settings/Pricing/PricingEditor.swift+++ b/Flux/Flux/Settings/Pricing/PricingEditor.swift@@ -1,10 +1,13 @@ import FluxCore import SwiftUI -/// Sheet for creating or editing a single pricing period. Rate inputs accept-/// up to four decimal places (AC 3.4). Validation errors from the backend-/// surface inline against the offending field when possible, otherwise as a-/// banner. The destructive delete sits behind a confirmation dialog (AC 3.7).+/// Sheet for creating or editing a single pricing plan. A plan is entered as a+/// default rate plus the exception windows that deviate from it (Decision 4);+/// the contiguous full-day segmentation is derived, so gaps and partial+/// coverage are unrepresentable. Rate inputs accept up to four decimal places.+/// Validation errors from the backend surface inline against the offending+/// field when possible, otherwise as a banner. The destructive delete sits+/// behind a confirmation dialog. @MainActor struct PricingEditor: View {     @Bindable var viewModel: PricingViewModel@@ -15,36 +18,9 @@ struct PricingEditor: View {     var body: some View {         NavigationStack {             Form {-                Section("Dates") {-                    DatePicker(-                        "Start",-                        selection: Binding(-                            get: { PricingEditor.parseDate(viewModel.draft.startDate) ?? Date() },-                            set: { viewModel.draft.startDate = PricingEditor.formatDate($0) }-                        ),-                        displayedComponents: .date-                    )-                    Toggle("Open-ended (no end date)", isOn: openEndedBinding)-                    if viewModel.draft.endDate != nil {-                        DatePicker(-                            "End",-                            selection: Binding(-                                get: {-                                    PricingEditor.parseDate(-                                        viewModel.draft.endDate ?? viewModel.draft.startDate-                                    ) ?? Date()-                                },-                                set: { viewModel.draft.endDate = PricingEditor.formatDate($0) }-                            ),-                            displayedComponents: .date-                        )-                    }-                }-                Section("Rates (AUD per kWh)") {-                    rateField(label: "Peak", value: $viewModel.draft.peakRate)-                    rateField(label: "Solar feed-in", value: $viewModel.draft.feedInRate)-                    rateField(label: "Off-peak savings", value: $viewModel.draft.offPeakSavingsRate)-                }+                datesSection+                ratesSection+                windowsSection                 if let inlineMessage = inlineValidationMessage {                     Section {                         Text(inlineMessage)@@ -52,41 +28,8 @@ struct PricingEditor: View {                             .foregroundStyle(.red)                     }                 }-                if viewModel.overlapRemediationTargetId != nil {-                    Section {-                        Button {-                            Task {-                                do {-                                    try await viewModel.remediateOverlap()-                                    dismiss()-                                } catch {-                                    // surfaces via lastValidationError-                                }-                            }-                        } label: {-                            Label("Close existing open-ended period and create",-                                  systemImage: "arrow.triangle.swap")-                        }-                        .buttonStyle(.borderedProminent)-                    } footer: {-                        Text(-                            "This closes the existing open-ended period at the day before this start date, " +-                            "then creates the new period — all in one transaction."-                        )-                        .font(.footnote)-                        .foregroundStyle(.secondary)-                    }-                }-                if case .edit = viewModel.editorMode {-                    Section {-                        Button(role: .destructive) {-                            showingDeleteConfirmation = true-                        } label: {-                            Label("Delete pricing period", systemImage: "trash")-                                .foregroundStyle(.red)-                        }-                    }-                }+                remediationSection+                deleteSection             }             .navigationTitle(navigationTitle)             #if os(iOS)@@ -114,14 +57,14 @@ struct PricingEditor: View {                 }             }             .confirmationDialog(-                "Delete this pricing period?",+                "Delete this pricing plan?",                 isPresented: $showingDeleteConfirmation,                 titleVisibility: .visible             ) {                 Button("Delete", role: .destructive) {                     Task {-                        if case .edit(let period) = viewModel.editorMode {-                            try? await viewModel.delete(period)+                        if case .edit(let plan) = viewModel.editorMode {+                            try? await viewModel.delete(plan)                             dismiss()                         }                     }@@ -131,11 +74,126 @@ struct PricingEditor: View {         }     } +    // MARK: - Sections++    private var datesSection: some View {+        Section {+            DatePicker(+                "Start",+                selection: Binding(+                    get: { PricingEditor.parseDate(viewModel.draft.startDate) ?? Date() },+                    set: { viewModel.draft.startDate = PricingEditor.formatDate($0) }+                ),+                displayedComponents: .date+            )+            Toggle("Open-ended (no end date)", isOn: openEndedBinding)+            if viewModel.draft.endDate != nil {+                DatePicker(+                    "Ends",+                    selection: Binding(+                        get: {+                            PricingEditor.parseDate(+                                viewModel.draft.endDate ?? viewModel.draft.startDate+                            ) ?? Date()+                        },+                        set: { viewModel.draft.endDate = PricingEditor.formatDate($0) }+                    ),+                    displayedComponents: .date+                )+            }+        } header: {+            Text("Dates")+        } footer: {+            if viewModel.draft.endDate != nil {+                // The end date is exclusive (Decision 5) — spelling that out+                // here is what stops someone entering an off-by-one date.+                Text("The plan's last priced day is the day before this date; a successor starting on it takes over.")+            }+        }+    }++    private var ratesSection: some View {+        Section {+            rateField(label: "Default", value: $viewModel.draft.defaultRate)+            rateField(label: "Solar feed-in", value: $viewModel.draft.feedInRate)+            if hasFreeWindow {+                rateField(label: "Savings reference", value: savingsReferenceBinding)+            }+        } header: {+            Text("Rates (AUD per kWh)")+        } footer: {+            Text("The default rate applies whenever no window below covers the time of day.")+        }+    }++    private var windowsSection: some View {+        Section {+            ForEach(Array(viewModel.draft.windows.indices), id: \.self) { index in+                PricingWindowRow(viewModel: viewModel, index: index)+            }+            Button {+                viewModel.addWindow()+            } label: {+                Label("Add window", systemImage: "plus")+            }+        } header: {+            Text("Windows")+        } footer: {+            Text("Times outside every window are charged at the default rate. A plan can have one free window.")+        }+    }++    @ViewBuilder+    private var remediationSection: some View {+        if viewModel.overlapRemediationTargetId != nil {+            Section {+                Button {+                    Task {+                        do {+                            try await viewModel.remediateOverlap()+                            dismiss()+                        } catch {+                            // surfaces via lastValidationError+                        }+                    }+                } label: {+                    Label("Close existing open-ended plan and create",+                          systemImage: "arrow.triangle.swap")+                }+                .buttonStyle(.borderedProminent)+            } footer: {+                Text(PricingViewModel.remediationFooter(startDate: viewModel.draft.startDate))+                    .font(.footnote)+                    .foregroundStyle(.secondary)+            }+        }+    }++    @ViewBuilder+    private var deleteSection: some View {+        if case .edit = viewModel.editorMode {+            Section {+                Button(role: .destructive) {+                    showingDeleteConfirmation = true+                } label: {+                    Label("Delete pricing plan", systemImage: "trash")+                        .foregroundStyle(.red)+                }+            }+        }+    }++    // MARK: - Derived state+     private var navigationTitle: String {         if case .edit = viewModel.editorMode { return "Edit pricing" }         return "New pricing"     } +    private var hasFreeWindow: Bool {+        viewModel.draft.windows.contains(where: \.free)+    }+     private var openEndedBinding: Binding<Bool> {         Binding(             get: { viewModel.draft.endDate == nil },@@ -149,6 +207,16 @@ struct PricingEditor: View {         )     } +    /// The savings reference rate is absent on a plan with no free window. The+    /// field only shows when a free window exists, and writing to it always+    /// produces a value.+    private var savingsReferenceBinding: Binding<Double> {+        Binding(+            get: { viewModel.draft.savingsReferenceRate ?? 0 },+            set: { viewModel.draft.savingsReferenceRate = $0 }+        )+    }+     private var inlineValidationMessage: String? {         if let reason = viewModel.lastValidationError {             switch reason {@@ -165,6 +233,8 @@ struct PricingEditor: View {         return nil     } +    // MARK: - Field builders+     private func rateField(label: String, value: Binding<Double>) -> some View {         HStack {             Text(label)@@ -188,14 +258,24 @@ struct PricingEditor: View { // MARK: - Static helpers (exposed for tests)  extension PricingEditor {-    static func localValidationMessage(for error: PricingPeriodDraft.ValidationError) -> String {+    static func localValidationMessage(for error: PricingPlanDraft.ValidationError) -> String {         switch error {         case .invalidStartDate:             return "Enter a valid start date (YYYY-MM-DD)."         case .invalidEndDate:             return "Enter a valid end date (YYYY-MM-DD)."         case .invertedDates:-            return "End date must not be before the start date."+            return "The end date must be after the start date."+        case .bandWindowInvalid:+            return "Each window needs a start before its end, between 00:00 and 24:00."+        case .bandOverlap:+            return "Windows must not overlap each other."+        case .multipleFreeBands:+            return "A plan can have at most one free window."+        case .noRatedBand:+            return "A free window covering the whole day leaves nothing to price."+        case .savingsRateMissing:+            return "A plan with a free window needs a savings reference rate."         case .rateOutOfRange:             return "Each rate must be between $0.00 and $10.00 per kWh."         case .ratePrecision:@@ -212,11 +292,47 @@ extension PricingEditor {         isoDateFormatter.string(from: date)     } +    /// Band boundaries are times of day, so they are edited on an arbitrary+    /// reference date the formatter then discards.+    static let bandTimeReference: Date = {+        var components = DateComponents()+        components.year = 2000+        components.month = 1+        components.day = 1+        return bandCalendar.date(from: components) ?? Date(timeIntervalSince1970: 0)+    }()++    /// Converts a stored "HH:MM" boundary to a pickable time. "24:00" has no+    /// clock representation, so it is held as 23:59 and mapped back by+    /// `formatBandTime` — without that, a plan whose last window ends at+    /// midnight could not be opened in the editor.+    static func parseBandTime(_ value: String) -> Date? {+        guard let minutes = PlanWindow.parseBandTime(value) else { return nil }+        let clamped = min(minutes, PlanWindow.minutesPerDay - 1)+        return bandCalendar.date(byAdding: .minute, value: clamped, to: bandTimeReference)+    }++    static func formatBandTime(_ date: Date) -> String {+        let components = bandCalendar.dateComponents([.hour, .minute], from: date)+        let minutes = (components.hour ?? 0) * 60 + (components.minute ?? 0)+        // 23:59 is the picker's stand-in for end-of-day; see parseBandTime.+        if minutes == PlanWindow.minutesPerDay - 1 {+            return PlanWindow.formatBandTime(PlanWindow.minutesPerDay)+        }+        return PlanWindow.formatBandTime(minutes)+    }++    private static let bandCalendar: Calendar = {+        var calendar = Calendar(identifier: .iso8601)+        calendar.timeZone = DateFormatting.sydneyTimeZone+        return calendar+    }()+     private static let isoDateFormatter: DateFormatter = {         let formatter = DateFormatter()         formatter.calendar = Calendar(identifier: .iso8601)         formatter.dateFormat = "yyyy-MM-dd"-        formatter.timeZone = TimeZone(identifier: "Australia/Melbourne") ?? .current+        formatter.timeZone = DateFormatting.sydneyTimeZone         return formatter     }() }
Flux/Flux/Settings/Pricing/PricingPeriodsView.swift Modified +52 / -31
diff --git a/Flux/Flux/Settings/Pricing/PricingPeriodsView.swift b/Flux/Flux/Settings/Pricing/PricingPeriodsView.swiftindex 40f2f1f..ab3e30e 100644--- a/Flux/Flux/Settings/Pricing/PricingPeriodsView.swift+++ b/Flux/Flux/Settings/Pricing/PricingPeriodsView.swift@@ -1,8 +1,9 @@ import FluxCore import SwiftUI -/// Shown from Settings → "Pricing". Lists configured pricing periods sorted-/// by start date ascending and offers add/edit/delete (Requirement 3).+/// Shown from Settings → "Pricing". Lists configured pricing plans sorted by+/// start date ascending and offers add/edit/delete. Each row shows the plan's+/// date range and the bands its rates apply over (AC 6.1). @MainActor struct PricingPeriodsView: View {     @State private var viewModel: PricingViewModel@@ -40,21 +41,21 @@ struct PricingPeriodsView: View {                 }             }             Section {-                if viewModel.periods.isEmpty {+                if viewModel.plans.isEmpty {                     emptyStateRow                 } else {-                    ForEach(viewModel.periods) { period in-                        periodRow(period)+                    ForEach(viewModel.plans) { plan in+                        planRow(plan)                     }                 }             } header: {-                Text("Periods")+                Text("Plans")             }             Section {                 Button {                     viewModel.beginCreate()                 } label: {-                    Label("Add pricing period", systemImage: "plus")+                    Label("Add pricing plan", systemImage: "plus")                 }             }         }@@ -67,14 +68,14 @@ struct PricingPeriodsView: View {                 if viewModel.showsErrorBanner {                     errorBanner                 }-                LiquidGlassSection(title: "Periods") {+                LiquidGlassSection(title: "Plans") {                     VStack(alignment: .leading, spacing: 8) {-                        if viewModel.periods.isEmpty {+                        if viewModel.plans.isEmpty {                             emptyStateRow                         } else {-                            ForEach(viewModel.periods) { period in-                                periodRow(period)-                                if period.id != viewModel.periods.last?.id {+                            ForEach(viewModel.plans) { plan in+                                planRow(plan)+                                if plan.id != viewModel.plans.last?.id {                                     Divider()                                 }                             }@@ -83,7 +84,7 @@ struct PricingPeriodsView: View {                         Button {                             viewModel.beginCreate()                         } label: {-                            Label("Add pricing period", systemImage: "plus")+                            Label("Add pricing plan", systemImage: "plus")                         }                     }                     .padding(8)@@ -115,21 +116,24 @@ struct PricingPeriodsView: View {         VStack(alignment: .leading, spacing: 4) {             Text("No pricing yet")                 .font(.body.weight(.semibold))-            Text("Add a pricing period to see daily costs on Day Detail and totals on History.")+            Text("Add a pricing plan to see daily costs on Day Detail and totals on History.")                 .font(.footnote)                 .foregroundStyle(.secondary)         }     }      @ViewBuilder-    private func periodRow(_ period: PricingPeriod) -> some View {+    private func planRow(_ plan: PricingPlan) -> some View {         Button {-            viewModel.beginEdit(period)+            viewModel.beginEdit(plan)         } label: {             VStack(alignment: .leading, spacing: 4) {-                Text(PricingPeriodsView.dateRangeText(for: period))+                Text(PricingPeriodsView.dateRangeText(for: plan))                     .font(.body.weight(.semibold))-                Text(PricingPeriodsView.rateSummary(for: period))+                Text(PricingPeriodsView.bandSummary(for: plan))+                    .font(.caption)+                    .foregroundStyle(.secondary)+                Text(PricingPeriodsView.feedInSummary(for: plan))                     .font(.caption)                     .foregroundStyle(.secondary)             }@@ -141,7 +145,7 @@ struct PricingPeriodsView: View {             Button(role: .destructive) {                 Task {                     do {-                        try await viewModel.delete(period)+                        try await viewModel.delete(plan)                     } catch {                         // PricingService.delete already recorded the error on                         // service.lastError before re-throwing; the@@ -161,27 +165,44 @@ struct PricingPeriodsView: View { // MARK: - Static formatters (exposed for unit tests)  extension PricingPeriodsView {-    static func dateRangeText(for period: PricingPeriod) -> String {-        if let end = period.endDate {-            return "\(period.startDate) – \(end)"+    /// The end date is exclusive (Decision 5): the plan's last priced day is+    /// the day before it. "until" says that; an inclusive-looking dash range+    /// would not.+    static func dateRangeText(for plan: PricingPlan) -> String {+        if let end = plan.endDate {+            return "\(plan.startDate) until \(end)"+        }+        return "from \(plan.startDate)"+    }++    /// The plan's bands as entered: the free window first (it is the one that+    /// changes behaviour elsewhere), then each rated exception, then the+    /// default rate that fills the rest of the day.+    static func bandSummary(for plan: PricingPlan) -> String {+        var parts: [String] = []+        if let free = plan.freeWindow {+            parts.append("Free \(free.start)–\(free.end)")+        }+        for window in plan.windows where !window.free {+            parts.append("\(formatRate(window.rate ?? 0)) \(window.start)–\(window.end)")         }-        return "from \(period.startDate)"+        parts.append("\(formatRate(plan.defaultRate)) default")+        return parts.joined(separator: " · ")     } -    static func rateSummary(for period: PricingPeriod) -> String {-        let peak = formatRate(period.peakRate)-        let feedIn = formatRate(period.feedInRate)-        let savings = formatRate(period.offPeakSavingsRate)-        return "Peak \(peak)/kWh · Feed-in \(feedIn)/kWh · Off-peak \(savings)/kWh"+    /// Feed-in is a single flat rate per plan, so it sits on its own line+    /// rather than competing with the import bands.+    static func feedInSummary(for plan: PricingPlan) -> String {+        "Feed-in \(formatRate(plan.feedInRate))/kWh"     }      static func formatRate(_ rate: Double) -> String {-        // 4dp rate display per AC 3.2 / Decision 10. Use a fixed format so+        // 4dp rate display (daily-costs Decision 10). Use a fixed format so         // the output is stable across locales.         String(format: "$%.4f", rate)     }      static let emptyStateTitle = "No pricing yet"-    static let emptyStateDetail = "Add a pricing period to see daily costs on Day Detail and totals on History."-    static let addButtonLabel = "Add pricing period"+    static let emptyStateDetail = "Add a pricing plan to see daily costs on Day Detail and totals on History."+    static let addButtonLabel = "Add pricing plan" }
Flux/Flux/Settings/Pricing/PricingViewModel.swift Modified +51 / -27
diff --git a/Flux/Flux/Settings/Pricing/PricingViewModel.swift b/Flux/Flux/Settings/Pricing/PricingViewModel.swiftindex 5281bdc..9806d70 100644--- a/Flux/Flux/Settings/Pricing/PricingViewModel.swift+++ b/Flux/Flux/Settings/Pricing/PricingViewModel.swift@@ -9,10 +9,10 @@ import Foundation final class PricingViewModel {     enum EditorMode: Equatable {         case create-        case edit(PricingPeriod)+        case edit(PricingPlan)     } -    var draft: PricingPeriodDraft = PricingPeriodDraft()+    var draft: PricingPlanDraft = PricingPlanDraft()     private(set) var editorMode: EditorMode?      /// The last `PricingValidationReason` surfaced by a failed save. The@@ -20,8 +20,8 @@ final class PricingViewModel {     /// banner. Cleared on the next successful save.     private(set) var lastValidationError: PricingValidationReason? -    /// When the last create error was an overlap with the open-ended period,-    /// the editor offers a one-tap remediation button (AC 3.6).+    /// When the last create error was an overlap with the open-ended plan,+    /// the editor offers a one-tap remediation button (AC 6.5).     private(set) var overlapRemediationTargetId: String?      let service: PricingService@@ -30,7 +30,7 @@ final class PricingViewModel {         self.service = service     } -    var periods: [PricingPeriod] { service.periods }+    var plans: [PricingPlan] { service.plans }      var lastErrorMessage: String? {         guard let err = service.lastError else { return nil }@@ -64,27 +64,54 @@ final class PricingViewModel {     }      func beginCreate() {-        draft = PricingPeriodDraft()+        draft = PricingPlanDraft()         editorMode = .create         lastValidationError = nil         overlapRemediationTargetId = nil     } -    func beginEdit(_ period: PricingPeriod) {-        draft = PricingPeriodDraft(period: period)-        editorMode = .edit(period)+    func beginEdit(_ plan: PricingPlan) {+        draft = PricingPlanDraft(plan: plan)+        editorMode = .edit(plan)         lastValidationError = nil         overlapRemediationTargetId = nil     } +    // MARK: - Window editing++    /// Appends a rated window seeded from the plan's default rate — the+    /// starting point for "this stretch costs something other than the norm".+    /// A free window is one toggle away.+    func addWindow() {+        draft.windows.append(+            PlanWindow(start: "00:00", end: "01:00", free: false, rate: draft.defaultRate)+        )+    }++    func removeWindow(at index: Int) {+        guard draft.windows.indices.contains(index) else { return }+        draft.windows.remove(at: index)+    }++    /// Toggles a window between free and rated. A free window carries no rate+    /// by contract, so the rate is dropped on the way in and re-seeded from the+    /// default rate on the way out rather than resurrecting a stale value.+    func setWindowFree(_ free: Bool, at index: Int) {+        guard draft.windows.indices.contains(index) else { return }+        draft.windows[index].free = free+        draft.windows[index].rate = free ? nil : draft.defaultRate+    }++    // MARK: - Persistence+     func save() async throws {         guard let mode = editorMode else { return }         do {             switch mode {             case .create:-                _ = try await service.create(normalisedDraft())+                _ = try await service.create(draft.normalised())             case .edit(let existing):-                _ = try await service.update(id: existing.id, normalisedDraft())+                _ = try await service.update(id: existing.id, draft.normalised())             }             editorMode = nil             lastValidationError = nil@@ -100,17 +127,18 @@ final class PricingViewModel {         }     } -    func delete(_ period: PricingPeriod) async throws {-        try await service.delete(id: period.id)+    func delete(_ plan: PricingPlan) async throws {+        try await service.delete(id: plan.id)     } -    /// One-tap remediation for the AC 3.6 flow. Closes the open-ended period-    /// at `draft.startDate − 1 day` and creates the new period in a single-    /// transactional request.+    /// One-tap remediation for the AC 6.5 flow. Ends the open-ended plan ON the+    /// new plan's start date and creates the successor, in a single+    /// transactional request. Under exclusive end dates both rows carry the+    /// same literal date and the switch day belongs to the successor (AC 2.2).     func remediateOverlap() async throws {         guard let closingId = overlapRemediationTargetId else { return }         do {-            _ = try await service.replaceOpenEnded(closingId: closingId, with: normalisedDraft())+            _ = try await service.replaceOpenEnded(closingId: closingId, with: draft.normalised())             editorMode = nil             lastValidationError = nil             overlapRemediationTargetId = nil@@ -127,15 +155,11 @@ final class PricingViewModel {         service.clearError()     } -    private func normalisedDraft() -> PricingPeriodDraft {-        // Persist rates at exactly four decimals so the wire payload matches-        // backend storage precision (Decision 10/20).-        PricingPeriodDraft(-            startDate: draft.startDate,-            endDate: draft.endDate,-            peakRate: PricingPeriodDraft.roundedToFourDP(draft.peakRate),-            feedInRate: PricingPeriodDraft.roundedToFourDP(draft.feedInRate),-            offPeakSavingsRate: PricingPeriodDraft.roundedToFourDP(draft.offPeakSavingsRate)-        )+    /// Explanatory copy under the remediation button. Phrased around the switch+    /// day rather than "the day before this start date": the predecessor now+    /// ends ON this date and the successor prices it (AC 6.5).+    static func remediationFooter(startDate: String) -> String {+        "This ends the existing open-ended plan on \(startDate) and starts the new one that same day — "+            + "all in one transaction."     } }
Flux/Flux/Settings/Pricing/PricingWindowRow.swift Added +91 / -0
diff --git a/Flux/Flux/Settings/Pricing/PricingWindowRow.swift b/Flux/Flux/Settings/Pricing/PricingWindowRow.swiftnew file mode 100644index 0000000..cdc9a13--- /dev/null+++ b/Flux/Flux/Settings/Pricing/PricingWindowRow.swift@@ -0,0 +1,91 @@+import FluxCore+import SwiftUI++/// One exception window in the pricing editor: its boundaries, whether it is+/// free, and — when it isn't — the rate it carries. Extracted from+/// `PricingEditor` so the sheet stays readable as the number of sections grew.+@MainActor+struct PricingWindowRow: View {+    @Bindable var viewModel: PricingViewModel+    let index: Int++    var body: some View {+        VStack(spacing: 8) {+            HStack {+                bandTimePicker(label: "From", isStart: true)+                Spacer(minLength: 12)+                bandTimePicker(label: "To", isStart: false)+            }+            Toggle("Free", isOn: Binding(+                get: { window?.free ?? false },+                set: { viewModel.setWindowFree($0, at: index) }+            ))+            if window?.free == false {+                rateField+            }+            Button(role: .destructive) {+                viewModel.removeWindow(at: index)+            } label: {+                Label("Remove window", systemImage: "minus.circle")+                    .font(.footnote)+            }+            .frame(maxWidth: .infinity, alignment: .leading)+        }+        .padding(.vertical, 4)+    }++    /// Nil while SwiftUI is still rendering a row whose window has just been+    /// removed — reading the array directly would trap on the stale index.+    private var window: PlanWindow? {+        viewModel.draft.windows.indices.contains(index) ? viewModel.draft.windows[index] : nil+    }++    private var rateField: some View {+        HStack {+            Text("Rate")+            Spacer()+            TextField(+                "0.0000",+                value: Binding(+                    get: { window?.rate ?? 0 },+                    set: { newValue in+                        guard viewModel.draft.windows.indices.contains(index) else { return }+                        viewModel.draft.windows[index].rate = newValue+                    }+                ),+                format: .number.precision(.fractionLength(4))+            )+            #if os(iOS)+            .keyboardType(.decimalPad)+            #endif+            .multilineTextAlignment(.trailing)+            .frame(maxWidth: 120)+            Text("/ kWh")+                .foregroundStyle(.secondary)+        }+    }++    private func bandTimePicker(label: String, isStart: Bool) -> some View {+        DatePicker(+            label,+            selection: Binding(+                get: {+                    let value = isStart ? window?.start : window?.end+                    return value.flatMap(PricingEditor.parseBandTime) ?? PricingEditor.bandTimeReference+                },+                set: { newValue in+                    guard viewModel.draft.windows.indices.contains(index) else { return }+                    let formatted = PricingEditor.formatBandTime(newValue)+                    if isStart {+                        viewModel.draft.windows[index].start = formatted+                    } else {+                        viewModel.draft.windows[index].end = formatted+                    }+                }+            ),+            displayedComponents: .hourAndMinute+        )+        .labelsHidden()+        .accessibilityLabel("\(label) time")+    }+}
Flux/FluxTests/Charts/DayChartExpansionTests.swift Modified +4 / -3
diff --git a/Flux/FluxTests/Charts/DayChartExpansionTests.swift b/Flux/FluxTests/Charts/DayChartExpansionTests.swiftindex a7f7c40..b782998 100644--- a/Flux/FluxTests/Charts/DayChartExpansionTests.swift+++ b/Flux/FluxTests/Charts/DayChartExpansionTests.swift@@ -23,7 +23,7 @@ struct DayChartExpansionTests {             Issue.record("Failed to parse fixture date string")             return         }-        let view = PowerChartView(date: dateString, readings: [], selectedDate: .constant(nil))+        let view = PowerChartView(date: dateString, readings: [], offpeakWindow: nil, selectedDate: .constant(nil))         #expect(view.expansionScope == .daySpecific(date: expected))     } @@ -38,6 +38,7 @@ struct DayChartExpansionTests {             date: dateString,             readings: [],             summary: nil,+            offpeakWindow: nil,             selectedDate: .constant(nil)         )         #expect(view.expansionScope == .daySpecific(date: expected))@@ -45,8 +46,8 @@ struct DayChartExpansionTests {      @Test("Different date strings produce distinct expansion scopes")     func differentDatesProduceDistinctScopes() {-        let first = PowerChartView(date: "2026-05-01", readings: [], selectedDate: .constant(nil))-        let second = PowerChartView(date: "2026-05-02", readings: [], selectedDate: .constant(nil))+        let first = PowerChartView(date: "2026-05-01", readings: [], offpeakWindow: nil, selectedDate: .constant(nil))+        let second = PowerChartView(date: "2026-05-02", readings: [], offpeakWindow: nil, selectedDate: .constant(nil))         #expect(first.expansionScope != second.expansionScope)     } }
Flux/FluxTests/PricingCostWiringTests.swift Added +318 / -0
diff --git a/Flux/FluxTests/PricingCostWiringTests.swift b/Flux/FluxTests/PricingCostWiringTests.swiftnew file mode 100644index 0000000..fc72a26--- /dev/null+++ b/Flux/FluxTests/PricingCostWiringTests.swift@@ -0,0 +1,318 @@+import FluxCore+import Foundation+import SwiftData+import Testing+@testable import Flux++/// The view models are the join point between the wire payloads and the cost+/// helper: they must hand over the band split, the off-peak row's geometry and+/// provenance, and the plan pricing the day. Anything dropped here silently+/// degrades a banded day to the fallback tier, so these tests assert on the+/// resolved tier, not just the numbers.+@MainActor @Suite(.serialized)+struct PricingCostWiringTests {+    // MARK: - Day Detail++    @Test+    func dayDetailPricesABandedDayFromTheStoredSplit() async throws {+        let apiClient = MockCostWiringAPIClient()+        apiClient.dayResponse = DayDetailResponse(+            date: "2026-08-15",+            readings: [],+            summary: bandedSummary(),+            peakPeriods: nil,+            dailyUsage: nil+        )+        let (viewModel, service) = try await makeDayDetail(date: "2026-08-15", apiClient: apiClient)+        await viewModel.loadDay()+        _ = service++        let costs = try #require(viewModel.costs)+        #expect(costs.tier == .banded, "the split, the off-peak row and the plan must all reach the helper")+        #expect(abs(costs.peakImportsCost - (1 * 0.35 + 4 * 0.28 + 2 * 0.35 + 8 * 0.35)) < 1e-9)+        #expect(abs(costs.offPeakSavings - 3 * 0.35) < 1e-9)+    }++    @Test+    func dayDetailFallsBackWhenTheSplitWasCapturedUnderAnotherWindow() async throws {+        let apiClient = MockCostWiringAPIClient()+        var summary = bandedSummary()+        // The off-peak row still carries the previous free window, so its+        // import cannot price this plan's free band (Q16).+        summary = DaySummary(+            epv: nil, eInput: 23, eOutput: 15,+            eCharge: nil, eDischarge: nil, socLow: nil, socLowTime: nil,+            offpeakGridImportKwh: 3,+            bandImports: summary.bandImports,+            offpeakWindowStart: "11:00",+            offpeakWindowEnd: "14:00",+            offpeakIntegratedAt: "2026-08-16T05:00:00Z",+            offpeakSampleCount: 1500+        )+        apiClient.dayResponse = DayDetailResponse(+            date: "2026-08-15", readings: [], summary: summary, peakPeriods: nil, dailyUsage: nil+        )+        let (viewModel, _) = try await makeDayDetail(date: "2026-08-15", apiClient: apiClient)+        await viewModel.loadDay()++        let costs = try #require(viewModel.costs)+        #expect(costs.tier == .fallback)+    }++    @Test+    func dayDetailHasNoCostsForAnUnpricedDay() async throws {+        let apiClient = MockCostWiringAPIClient()+        apiClient.dayResponse = DayDetailResponse(+            date: "2020-01-01", readings: [], summary: bandedSummary(), peakPeriods: nil, dailyUsage: nil+        )+        let (viewModel, _) = try await makeDayDetail(date: "2020-01-01", apiClient: apiClient)+        await viewModel.loadDay()+        #expect(viewModel.costs == nil, "days no plan covers show no cost data (AC 2.7)")+    }++    @Test+    func dayDetailTakesItsFreeWindowFromThePlanPricingThatDay() async throws {+        let apiClient = MockCostWiringAPIClient()+        let (switchDay, _) = try await makeDayDetail(date: "2026-08-01", apiClient: apiClient)+        #expect(switchDay.offpeakWindow?.start == "10:00")+        #expect(switchDay.offpeakWindow?.end == "15:00")++        let (switchEve, _) = try await makeDayDetail(date: "2026-07-31", apiClient: apiClient)+        #expect(switchEve.offpeakWindow?.start == "11:00")+        #expect(switchEve.offpeakWindow?.end == "14:00")+    }++    @Test+    func anUnpricedDayGetsNoWindowRatherThanADefault() async throws {+        let apiClient = MockCostWiringAPIClient()+        let (viewModel, _) = try await makeDayDetail(date: "2020-01-01", apiClient: apiClient)+        #expect(viewModel.offpeakWindow == nil, "no plan means no window, never a substituted default")+    }++    // MARK: - History++    @Test+    func historyPricesEachDayUnderItsOwnPlanAcrossASwitchDate() async throws {+        let apiClient = MockCostWiringAPIClient()+        apiClient.historyResponse = HistoryResponse(days: [+            day(date: "2026-07-31", eInput: 10),+            day(date: "2026-08-01", eInput: 10)+        ])+        let (viewModel, _) = try await makeHistory(apiClient: apiClient)+        await viewModel.loadHistory(range: .days(7))++        let costs = try #require(viewModel.periodCosts)+        #expect(costs.pricedDayCount == 2)+        // Predecessor rate 0.2873 on the 31st, successor's highest rate 0.35+        // (fallback: multi-rate plan with no split) on the switch day.+        #expect(abs(costs.peakImportsCost - (10 * 0.2873 + 10 * 0.35)) < 1e-9)+    }++    @Test+    func historyRetainsThePartialCoverageCount() async throws {+        let apiClient = MockCostWiringAPIClient()+        apiClient.historyResponse = HistoryResponse(days: [+            day(date: "2026-08-01", eInput: 10),+            day(date: "2020-01-01", eInput: 10),+            day(date: "2020-01-02", eInput: 10)+        ])+        let (viewModel, _) = try await makeHistory(apiClient: apiClient)+        await viewModel.loadHistory(range: .days(7))++        let costs = try #require(viewModel.periodCosts)+        #expect(costs.pricedDayCount == 1)+        #expect(costs.totalDayCount == 3)+        #expect(costs.hasPartialCoverage, "AC 3.7's N of M days priced caption must survive")+    }++    @Test+    func historyPricesABandedDayFromTheStoredSplit() async throws {+        let apiClient = MockCostWiringAPIClient()+        apiClient.historyResponse = HistoryResponse(days: [bandedDay()])+        let (viewModel, _) = try await makeHistory(apiClient: apiClient)+        await viewModel.loadHistory(range: .days(7))++        let costs = try #require(viewModel.periodCosts)+        #expect(abs(costs.peakImportsCost - (1 * 0.35 + 4 * 0.28 + 2 * 0.35 + 8 * 0.35)) < 1e-9)+        #expect(abs(costs.offPeakSavings - 3 * 0.35) < 1e-9)+    }++    // MARK: - Data consistency++    @Test+    func dayDetailAndHistoryReportTheSameCostForTheSameDay() async throws {+        let dayClient = MockCostWiringAPIClient()+        dayClient.dayResponse = DayDetailResponse(+            date: "2026-08-15", readings: [], summary: bandedSummary(), peakPeriods: nil, dailyUsage: nil+        )+        let (dayViewModel, _) = try await makeDayDetail(date: "2026-08-15", apiClient: dayClient)+        await dayViewModel.loadDay()++        let historyClient = MockCostWiringAPIClient()+        historyClient.historyResponse = HistoryResponse(days: [bandedDay()])+        let (historyViewModel, _) = try await makeHistory(apiClient: historyClient)+        await historyViewModel.loadHistory(range: .days(7))++        let dayCosts = try #require(dayViewModel.costs)+        let periodCosts = try #require(historyViewModel.periodCosts)+        #expect(abs(dayCosts.peakImportsCost - periodCosts.peakImportsCost) < 1e-9)+        #expect(abs(dayCosts.solarFeedInIncome - periodCosts.solarFeedInIncome) < 1e-9)+        #expect(abs(dayCosts.net - periodCosts.net) < 1e-9)+        #expect(abs(dayCosts.offPeakSavings - periodCosts.offPeakSavings) < 1e-9)+    }++    @Test+    func aCachedDayKeepsItsBandSplitSoOfflineCostsMatchTheLiveOnes() throws {+        // History falls back to the SwiftData cache when a fetch fails. If the+        // cache drops the split or the off-peak row's geometry, the same day+        // silently reprices at the fallback tier the moment the network blips.+        let live = bandedDay()+        let restored = CachedDayEnergy(from: live).asDayEnergy++        #expect(restored.bandImports == live.bandImports)+        #expect(restored.offpeakWindowStart == live.offpeakWindowStart)+        #expect(restored.offpeakWindowEnd == live.offpeakWindowEnd)+        #expect(restored.offpeakIntegratedAt == live.offpeakIntegratedAt)+        #expect(restored.offpeakSampleCount == live.offpeakSampleCount)++        let liveCosts = try #require(live.costs(in: Self.plans))+        let offlineCosts = try #require(restored.costs(in: Self.plans))+        #expect(liveCosts == offlineCosts)+        #expect(offlineCosts.tier == .banded)+    }++    // MARK: - Fixtures++    /// The plans either side of the switch date: the migrated single-rate plan+    /// until 2026-08-01, the time-of-use plan from it.+    private static let plans: [PricingPlan] = [+        PricingPlan(+            id: "old", startDate: "2026-01-01", endDate: "2026-08-01",+            defaultRate: 0.2873,+            windows: [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)],+            feedInRate: 0.05, savingsReferenceRate: 0.2873,+            createdAt: Date(timeIntervalSince1970: 1), updatedAt: Date(timeIntervalSince1970: 1)+        ),+        PricingPlan(+            id: "new", startDate: "2026-08-01", endDate: nil,+            defaultRate: 0.35,+            windows: [+                PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+                PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)+            ],+            feedInRate: 0.05, savingsReferenceRate: 0.35,+            createdAt: Date(timeIntervalSince1970: 1), updatedAt: Date(timeIntervalSince1970: 1)+        )+    ]++    private static let split = [+        BandImport(start: "00:00", end: "01:00", kwh: 1),+        BandImport(start: "01:00", end: "06:00", kwh: 4),+        BandImport(start: "06:00", end: "10:00", kwh: 2),+        BandImport(start: "15:00", end: "24:00", kwh: 8)+    ]++    private func bandedSummary() -> DaySummary {+        DaySummary(+            epv: nil, eInput: 23, eOutput: 15,+            eCharge: nil, eDischarge: nil, socLow: nil, socLowTime: nil,+            offpeakGridImportKwh: 3,+            bandImports: Self.split,+            offpeakWindowStart: "10:00",+            offpeakWindowEnd: "15:00",+            offpeakIntegratedAt: "2026-08-16T05:00:00Z",+            offpeakSampleCount: 1500+        )+    }++    private func bandedDay() -> DayEnergy {+        DayEnergy(+            date: "2026-08-15",+            epv: 0, eInput: 23, eOutput: 15, eCharge: 0, eDischarge: 0,+            offpeakGridImportKwh: 3,+            bandImports: Self.split,+            offpeakWindowStart: "10:00",+            offpeakWindowEnd: "15:00",+            offpeakIntegratedAt: "2026-08-16T05:00:00Z",+            offpeakSampleCount: 1500,+            note: nil+        )+    }++    private func day(date: String, eInput: Double) -> DayEnergy {+        DayEnergy(+            date: date,+            epv: 0, eInput: eInput, eOutput: 0, eCharge: 0, eDischarge: 0,+            note: nil+        )+    }++    private func makePricingService(apiClient: MockCostWiringAPIClient) async -> PricingService {+        let service = PricingService()+        service.bind(apiClient: apiClient)+        try? await service.refresh()+        return service+    }++    private func makeDayDetail(+        date: String,+        apiClient: MockCostWiringAPIClient+    ) async throws -> (DayDetailViewModel, PricingService) {+        apiClient.plansToReturn = Self.plans+        let service = await makePricingService(apiClient: apiClient)+        return (DayDetailViewModel(date: date, apiClient: apiClient, pricingService: service), service)+    }++    private func makeHistory(+        apiClient: MockCostWiringAPIClient+    ) async throws -> (HistoryViewModel, PricingService) {+        apiClient.plansToReturn = Self.plans+        let service = await makePricingService(apiClient: apiClient)+        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)+        let container = try ModelContainer(for: CachedDayEnergy.self, configurations: configuration)+        let context = ModelContext(container)+        let viewModel = HistoryViewModel(+            apiClient: apiClient,+            modelContext: context,+            pricingService: service+        )+        return (viewModel, service)+    }+}++// MARK: - test double++private final class MockCostWiringAPIClient: FluxAPIClient, @unchecked Sendable {+    var plansToReturn: [PricingPlan] = []+    var dayResponse: DayDetailResponse?+    var historyResponse: HistoryResponse?++    func fetchStatus() async throws -> StatusResponse {+        StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil, note: nil)+    }++    func fetchHistory(days _: Int) async throws -> HistoryResponse {+        guard let historyResponse else { throw FluxAPIError.notConfigured }+        return historyResponse+    }++    func fetchHistory(query _: HistoryQuery) async throws -> HistoryResponse {+        guard let historyResponse else { throw FluxAPIError.notConfigured }+        return historyResponse+    }++    func fetchDay(date: String) async throws -> DayDetailResponse {+        guard let dayResponse else {+            return DayDetailResponse(+                date: date, readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil+            )+        }+        return dayResponse+    }++    func saveNote(date: String, text _: String) async throws -> NoteResponse {+        NoteResponse(date: date, text: "", updatedAt: nil)+    }++    func fetchPricing() async throws -> [PricingPlan] { plansToReturn }+}
Flux/FluxTests/Settings/PricingEditorTests.swift Modified +64 / -27
diff --git a/Flux/FluxTests/Settings/PricingEditorTests.swift b/Flux/FluxTests/Settings/PricingEditorTests.swiftindex 88b9650..990f79f 100644--- a/Flux/FluxTests/Settings/PricingEditorTests.swift+++ b/Flux/FluxTests/Settings/PricingEditorTests.swift@@ -10,18 +10,36 @@ import Testing struct PricingEditorTests {     @Test     func localValidationMessagesNameTheOffendingField() throws {-        #expect(PricingEditor.localValidationMessage(for: .invertedDates).contains("End date"))+        #expect(PricingEditor.localValidationMessage(for: .invertedDates).contains("end date"))         #expect(PricingEditor.localValidationMessage(for: .ratePrecision).contains("four decimal"))         #expect(PricingEditor.localValidationMessage(for: .rateOutOfRange).contains("$0.00"))         #expect(PricingEditor.localValidationMessage(for: .invalidStartDate).contains("YYYY-MM-DD"))     } +    @Test+    func everyLocalValidationErrorHasAMessage() {+        let errors: [PricingPlanDraft.ValidationError] = [+            .invalidStartDate, .invalidEndDate, .invertedDates,+            .bandWindowInvalid, .bandOverlap, .multipleFreeBands,+            .noRatedBand, .savingsRateMissing, .rateOutOfRange, .ratePrecision+        ]+        for error in errors {+            #expect(!PricingEditor.localValidationMessage(for: error).isEmpty, "\(error)")+        }+    }++    @Test+    func bandValidationMessagesDescribeTheBandRule() {+        #expect(PricingEditor.localValidationMessage(for: .bandOverlap).lowercased().contains("overlap"))+        #expect(PricingEditor.localValidationMessage(for: .multipleFreeBands).lowercased().contains("free"))+        #expect(PricingEditor.localValidationMessage(for: .savingsRateMissing).lowercased().contains("savings"))+    }+     @Test     func dateRoundTripFormatsAsISO() throws {         let date = PricingEditor.parseDate("2026-04-15")         let unwrapped = try #require(date)-        let formatted = PricingEditor.formatDate(unwrapped)-        #expect(formatted == "2026-04-15")+        #expect(PricingEditor.formatDate(unwrapped) == "2026-04-15")     }      @Test@@ -29,41 +47,55 @@ struct PricingEditorTests {         #expect(PricingEditor.parseDate("") == nil)     } -    // MARK: - Editor mode via ViewModel+    // MARK: - Band time pickers++    @Test+    func bandTimeRoundTripsThroughTheTimePicker() throws {+        let date = try #require(PricingEditor.parseBandTime("10:30"))+        #expect(PricingEditor.formatBandTime(date) == "10:30")+    }++    @Test+    func endOfDayRoundTripsAsTwentyFourHundred() throws {+        // 24:00 has no clock representation, so the picker holds it as 23:59+        // and the formatter maps that one minute back to end-of-day. Without+        // this a plan whose last window ends at midnight could not be edited.+        let date = try #require(PricingEditor.parseBandTime("24:00"))+        #expect(PricingEditor.formatBandTime(date) == "24:00")+    }++    @Test+    func parseBandTimeRejectsMalformedInput() {+        #expect(PricingEditor.parseBandTime("nonsense") == nil)+        #expect(PricingEditor.parseBandTime("") == nil)+    }++    // MARK: - Draft validation through the editor      @Test     func draftValidatesOnTypicalCreateInputs() throws {-        let draft = PricingPeriodDraft(+        let draft = PricingPlanDraft(             startDate: "2026-08-01",             endDate: nil,-            peakRate: 0.2873,+            defaultRate: 0.2873,+            windows: [PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil)],             feedInRate: 0.05,-            offPeakSavingsRate: 0.12+            savingsReferenceRate: 0.12         )         #expect(draft.validate() == nil)     }      @Test     func draftRejectsRatePrecisionGreaterThan4DP() throws {-        let draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: nil,-            peakRate: 0.123456,-            feedInRate: 0.05,-            offPeakSavingsRate: 0.12-        )+        var draft = makeDraft()+        draft.defaultRate = 0.123456         #expect(draft.validate() == .ratePrecision)     }      @Test     func draftRejectsRateOutOfRange() throws {-        let draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: nil,-            peakRate: 11.0,-            feedInRate: 0.05,-            offPeakSavingsRate: 0.12-        )+        var draft = makeDraft()+        draft.defaultRate = 11.0         #expect(draft.validate() == .rateOutOfRange)     } @@ -75,15 +107,20 @@ struct PricingEditorTests {         service.bind(apiClient: apiClient)         let viewModel = PricingViewModel(service: service)         viewModel.beginCreate()-        viewModel.draft = PricingPeriodDraft(+        viewModel.draft = makeDraft()+        try? await viewModel.save()+        #expect(viewModel.overlapRemediationTargetId == "pp-open")+        #expect(viewModel.lastValidationError == .overlap(openEndedId: "pp-open"))+    }++    private func makeDraft() -> PricingPlanDraft {+        PricingPlanDraft(             startDate: "2026-08-01",             endDate: nil,-            peakRate: 0.30,+            defaultRate: 0.30,+            windows: [PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil)],             feedInRate: 0.06,-            offPeakSavingsRate: 0.12+            savingsReferenceRate: 0.12         )-        try? await viewModel.save()-        #expect(viewModel.overlapRemediationTargetId == "pp-open")-        #expect(viewModel.lastValidationError == .overlap(openEndedId: "pp-open"))     } }
Flux/FluxTests/Settings/PricingPeriodsViewTests.swift Modified +66 / -32
diff --git a/Flux/FluxTests/Settings/PricingPeriodsViewTests.swift b/Flux/FluxTests/Settings/PricingPeriodsViewTests.swiftindex 65b5902..53b212a 100644--- a/Flux/FluxTests/Settings/PricingPeriodsViewTests.swift+++ b/Flux/FluxTests/Settings/PricingPeriodsViewTests.swift@@ -4,43 +4,76 @@ import Testing @testable import Flux  /// Lightweight view-state tests for PricingPeriodsView. The project has no-/// snapshot-test framework wired up (per the task brief), so these tests-/// assert on the static helpers that drive the view's text content rather-/// than rendering view trees.+/// snapshot-test framework wired up, so these tests assert on the static+/// helpers that drive the view's text content rather than rendering view trees. @MainActor @Suite struct PricingPeriodsViewTests {     @Test-    func closedPeriodFormatsAsStartEnd() {-        let period = makePeriod(start: "2026-01-01", end: "2026-06-30")-        #expect(PricingPeriodsView.dateRangeText(for: period) == "2026-01-01 – 2026-06-30")+    func closedPlanFormatsAsStartUntilEnd() {+        // The end date is exclusive (Decision 5), so the range reads as+        // "until" rather than an inclusive dash range that would look like+        // the plan prices the switch day.+        let plan = makePlan(start: "2026-01-01", end: "2026-08-01")+        #expect(PricingPeriodsView.dateRangeText(for: plan) == "2026-01-01 until 2026-08-01")     }      @Test-    func openEndedPeriodFormatsAsFromStart() {-        let period = makePeriod(start: "2026-07-01", end: nil)-        #expect(PricingPeriodsView.dateRangeText(for: period) == "from 2026-07-01")+    func openEndedPlanFormatsAsFromStart() {+        let plan = makePlan(start: "2026-07-01", end: nil)+        #expect(PricingPeriodsView.dateRangeText(for: plan) == "from 2026-07-01")+    }++    // MARK: - Band summary (AC 6.1)++    @Test+    func bandSummaryListsTheFreeWindowThenExceptionsThenTheDefault() {+        let plan = makePlan(+            start: "2026-08-01",+            end: nil,+            defaultRate: 0.35,+            windows: [+                PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+                PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)+            ],+            savings: 0.35+        )+        let summary = PricingPeriodsView.bandSummary(for: plan)+        #expect(summary == "Free 10:00–15:00 · $0.2800 01:00–06:00 · $0.3500 default")+    }++    @Test+    func bandSummaryOfAFlatPlanIsJustTheDefaultRate() {+        let plan = makePlan(start: "2026-01-01", end: nil, defaultRate: 0.35, windows: [], savings: nil)+        #expect(PricingPeriodsView.bandSummary(for: plan) == "$0.3500 default")     }      @Test-    func rateSummaryUsesFourDecimalPlaces() {-        let period = makePeriod(start: "2026-01-01", end: nil, peak: 0.2873, feedIn: 0.05, offPeak: 0.1234)-        let summary = PricingPeriodsView.rateSummary(for: period)-        #expect(summary.contains("$0.2873"))-        #expect(summary.contains("$0.0500"))-        #expect(summary.contains("$0.1234"))+    func bandSummaryOfAMigratedPlanNamesItsFreeWindow() {+        let plan = makePlan(+            start: "2026-01-01",+            end: nil,+            defaultRate: 0.2873,+            windows: [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)],+            savings: 0.2873+        )+        #expect(PricingPeriodsView.bandSummary(for: plan) == "Free 11:00–14:00 · $0.2873 default")     }      @Test     func formatRateAlwaysShowsFourDecimalPlaces() {         #expect(PricingPeriodsView.formatRate(0.05) == "$0.0500")-        #expect(PricingPeriodsView.formatRate(0.123456) == "$0.1235",-                "rounds to 4 decimal places")+        #expect(PricingPeriodsView.formatRate(0.123456) == "$0.1235", "rounds to 4 decimal places")         #expect(PricingPeriodsView.formatRate(0) == "$0.0000")     } +    @Test+    func feedInSummaryIsShownSeparatelyFromTheImportBands() {+        let plan = makePlan(start: "2026-01-01", end: nil, defaultRate: 0.35, windows: [], savings: nil)+        #expect(PricingPeriodsView.feedInSummary(for: plan).contains("$0.0500"))+    }+     @Test     func emptyStateCopyNamesUnlockedFeatures() {-        // AC 3.8 — empty state names what the feature unlocks.         let detail = PricingPeriodsView.emptyStateDetail         #expect(detail.contains("Day Detail"))         #expect(detail.contains("History"))@@ -49,36 +82,37 @@ struct PricingPeriodsViewTests {     // MARK: - sorted state via ViewModel      @Test-    func viewModelExposesPeriodsSortedAscending() async {+    func viewModelExposesPlansSortedAscending() async {         let apiClient = TestPricingAPIClient()-        apiClient.periodsToReturn = [-            makePeriod(id: "newer", start: "2026-07-01", end: nil),-            makePeriod(id: "older", start: "2026-01-01", end: "2026-06-30")+        apiClient.plansToReturn = [+            makePlan(id: "newer", start: "2026-07-01", end: nil),+            makePlan(id: "older", start: "2026-01-01", end: "2026-07-01")         ]         let service = PricingService()         service.bind(apiClient: apiClient)         let viewModel = PricingViewModel(service: service)         await viewModel.refresh()-        #expect(viewModel.periods.map(\.id) == ["older", "newer"])+        #expect(viewModel.plans.map(\.id) == ["older", "newer"])     }      // MARK: - helpers -    private func makePeriod(+    private func makePlan(         id: String = "p",         start: String,         end: String?,-        peak: Double = 0.30,-        feedIn: Double = 0.05,-        offPeak: Double = 0.12-    ) -> PricingPeriod {-        PricingPeriod(+        defaultRate: Double = 0.30,+        windows: [PlanWindow] = [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)],+        savings: Double? = 0.12+    ) -> PricingPlan {+        PricingPlan(             id: id,             startDate: start,             endDate: end,-            peakRate: peak,-            feedInRate: feedIn,-            offPeakSavingsRate: offPeak,+            defaultRate: defaultRate,+            windows: windows,+            feedInRate: 0.05,+            savingsReferenceRate: savings,             createdAt: Date(timeIntervalSince1970: 1),             updatedAt: Date(timeIntervalSince1970: 1)         )
Flux/FluxTests/Settings/PricingViewModelTests.swift Modified +257 / -99
diff --git a/Flux/FluxTests/Settings/PricingViewModelTests.swift b/Flux/FluxTests/Settings/PricingViewModelTests.swiftindex 4f52d27..b32fbc4 100644--- a/Flux/FluxTests/Settings/PricingViewModelTests.swift+++ b/Flux/FluxTests/Settings/PricingViewModelTests.swift@@ -3,39 +3,44 @@ import Foundation import Testing @testable import Flux +// swiftlint:disable file_length type_body_length @MainActor @Suite(.serialized) struct PricingViewModelTests {     @Test-    func refreshLoadsPeriodsFromService() async throws {+    func refreshLoadsPlansFromService() async throws {         let (viewModel, apiClient) = makeViewModelAndAPI()-        apiClient.periodsToReturn = [makePeriod(id: "p1", start: "2026-01-01", end: "2026-06-30")]+        apiClient.plansToReturn = [makePlan(id: "p1", start: "2026-01-01", end: "2026-07-01")]         await viewModel.refresh()-        #expect(viewModel.periods.count == 1)-        #expect(viewModel.periods.first?.id == "p1")+        #expect(viewModel.plans.count == 1)+        #expect(viewModel.plans.first?.id == "p1")     }      @Test-    func beginCreateSeedsBlankDraft() {+    func beginCreateSeedsADraftWithTheCurrentFreeWindow() {         let (viewModel, _) = makeViewModelAndAPI()         viewModel.beginCreate()         #expect(viewModel.isEditorPresented)         #expect(viewModel.editorMode == .create)         #expect(viewModel.draft.startDate == "")+        #expect(viewModel.draft.windows.isEmpty)     }      @Test-    func beginEditSeedsDraftFromPeriod() {+    func beginEditSeedsDraftFromPlanIncludingWindows() {         let (viewModel, _) = makeViewModelAndAPI()-        let period = makePeriod(id: "p1", start: "2026-01-01", end: "2026-06-30", peak: 0.30)-        viewModel.beginEdit(period)+        let plan = makeTouPlan(id: "p1", start: "2026-08-01", end: nil)+        viewModel.beginEdit(plan)         #expect(viewModel.isEditorPresented)         if case .edit(let target) = viewModel.editorMode {             #expect(target.id == "p1")         } else {             Issue.record("expected edit mode")         }-        #expect(viewModel.draft.peakRate == 0.30)-        #expect(viewModel.draft.startDate == "2026-01-01")+        #expect(viewModel.draft.defaultRate == 0.35)+        #expect(viewModel.draft.startDate == "2026-08-01")+        #expect(viewModel.draft.windows.count == 2)+        #expect(viewModel.draft.windows[0].free)+        #expect(viewModel.draft.windows[1].rate == 0.28)     }      @Test@@ -47,59 +52,131 @@ struct PricingViewModelTests {         #expect(!viewModel.isEditorPresented)     } +    // MARK: - Window editing+     @Test-    func saveCreateAppendsRow() async throws {+    func addWindowAppendsARatedWindowAtTheDefaultRate() {         let (viewModel, _) = makeViewModelAndAPI()         viewModel.beginCreate()-        viewModel.draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: nil,-            peakRate: 0.30,-            feedInRate: 0.06,-            offPeakSavingsRate: 0.12-        )+        viewModel.draft.defaultRate = 0.35+        viewModel.addWindow()+        #expect(viewModel.draft.windows.count == 1)+        #expect(!viewModel.draft.windows[0].free)+        #expect(viewModel.draft.windows[0].rate == 0.35)+    }++    @Test+    func removeWindowDropsTheRowAtTheGivenIndex() {+        let (viewModel, _) = makeViewModelAndAPI()+        viewModel.beginEdit(makeTouPlan(id: "p1", start: "2026-08-01", end: nil))+        viewModel.removeWindow(at: 0)+        #expect(viewModel.draft.windows.count == 1)+        #expect(viewModel.draft.windows[0].start == "01:00")+    }++    @Test+    func markingAWindowFreeDropsItsRate() {+        let (viewModel, _) = makeViewModelAndAPI()+        viewModel.beginCreate()+        viewModel.draft.defaultRate = 0.35+        viewModel.addWindow()+        viewModel.setWindowFree(true, at: 0)+        #expect(viewModel.draft.windows[0].free)+        #expect(viewModel.draft.windows[0].rate == nil)+    }++    @Test+    func markingAWindowRatedSeedsItFromTheDefaultRate() {+        let (viewModel, _) = makeViewModelAndAPI()+        viewModel.beginEdit(makeTouPlan(id: "p1", start: "2026-08-01", end: nil))+        viewModel.setWindowFree(false, at: 0)+        #expect(!viewModel.draft.windows[0].free)+        #expect(viewModel.draft.windows[0].rate == 0.35)+    }++    @Test+    func canSaveMirrorsDraftValidation() {+        let (viewModel, _) = makeViewModelAndAPI()+        viewModel.beginCreate()+        #expect(!viewModel.canSave, "a blank draft has no valid start date")++        viewModel.draft = makeDraft()+        #expect(viewModel.canSave)++        // A free window with no savings reference rate is rejected locally,+        // mirroring the server (AC 6.4).+        viewModel.draft.savingsReferenceRate = nil+        #expect(!viewModel.canSave)+    }++    // MARK: - Save++    @Test+    func saveCreateAppendsPlan() async throws {+        let (viewModel, _) = makeViewModelAndAPI()+        viewModel.beginCreate()+        viewModel.draft = makeDraft()         try await viewModel.save()-        #expect(viewModel.periods.contains(where: { $0.startDate == "2026-08-01" }))-        #expect(!viewModel.isEditorPresented, "editor must dismiss after alpha successful save")+        #expect(viewModel.plans.contains(where: { $0.startDate == "2026-08-01" }))+        #expect(!viewModel.isEditorPresented, "editor must dismiss after a successful save")     }      @Test-    func saveEditUpdatesRow() async throws {+    func saveNormalisesEveryRateToFourDecimalPlaces() async throws {         let (viewModel, apiClient) = makeViewModelAndAPI()-        let period = makePeriod(id: "p1", start: "2026-01-01", end: "2026-06-30", peak: 0.28)-        apiClient.periodsToReturn = [period]+        viewModel.beginCreate()+        var draft = makeDraft()+        draft.defaultRate = 0.354321+        draft.feedInRate = 0.056789+        draft.savingsReferenceRate = 0.351111+        draft.windows = [+            PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+            PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.284567)+        ]+        viewModel.draft = draft+        try await viewModel.save()++        let sent = try #require(apiClient.lastCreatedDraft)+        #expect(sent.defaultRate == 0.3543)+        #expect(sent.feedInRate == 0.0568)+        #expect(sent.savingsReferenceRate == 0.3511)+        #expect(sent.windows[1].rate == 0.2846)+        // A free window carries no rate by contract.+        #expect(sent.windows[0].rate == nil)+    }++    @Test+    func saveEditUpdatesPlan() async throws {+        let (viewModel, apiClient) = makeViewModelAndAPI()+        let plan = makePlan(id: "p1", start: "2026-01-01", end: "2026-07-01", defaultRate: 0.28)+        apiClient.plansToReturn = [plan]         await viewModel.refresh()-        viewModel.beginEdit(period)-        viewModel.draft.peakRate = 0.32+        viewModel.beginEdit(plan)+        viewModel.draft.defaultRate = 0.32         try await viewModel.save()         // foldReplace runs synchronously inside service.update, so-        // viewModel.periods reflects the new rate as soon as save() returns-        // — no need to wait for the fire-and-forget refetch here.-        #expect(viewModel.periods.first?.peakRate == 0.32)+        // viewModel.plans reflects the new rate as soon as save() returns.+        #expect(viewModel.plans.first?.defaultRate == 0.32)     }      @Test-    func deleteRemovesPeriod() async throws {+    func deleteRemovesPlan() async throws {         let (viewModel, apiClient) = makeViewModelAndAPI()-        let period = makePeriod(id: "p1", start: "2026-01-01", end: "2026-06-30")-        apiClient.periodsToReturn = [period]+        let plan = makePlan(id: "p1", start: "2026-01-01", end: "2026-07-01")+        apiClient.plansToReturn = [plan]         await viewModel.refresh()-        try await viewModel.delete(period)-        #expect(viewModel.periods.isEmpty)+        try await viewModel.delete(plan)+        #expect(viewModel.plans.isEmpty)     } +    // MARK: - Validation errors+     @Test     func saveSurfacesOverlapErrorAsBanner() async throws {         let (viewModel, apiClient) = makeViewModelAndAPI()         apiClient.nextCreateError = .pricingValidation(.overlap(openEndedId: "open-id"))         viewModel.beginCreate()-        viewModel.draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: nil,-            peakRate: 0.30,-            feedInRate: 0.06,-            offPeakSavingsRate: 0.12-        )+        viewModel.draft = makeDraft()         do {             try await viewModel.save()             Issue.record("expected save to throw")@@ -112,66 +189,101 @@ struct PricingViewModelTests {     }      @Test-    func saveSurfacesInvertedDatesAsValidationError() async throws {+    func anOverlapWithANonOpenEndedPlanOffersNoRemediation() async throws {         let (viewModel, apiClient) = makeViewModelAndAPI()-        apiClient.nextCreateError = .pricingValidation(.invertedDates)+        apiClient.nextCreateError = .pricingValidation(.overlap(openEndedId: nil))         viewModel.beginCreate()-        viewModel.draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: "2026-07-31",-            peakRate: 0.30,-            feedInRate: 0.05,-            offPeakSavingsRate: 0.12-        )-        do {-            try await viewModel.save()-            Issue.record("expected throw")-        } catch {}-        #expect(viewModel.lastValidationError == .invertedDates)+        viewModel.draft = makeDraft()+        try? await viewModel.save()+        #expect(viewModel.lastValidationError == .overlap(openEndedId: nil))+        #expect(viewModel.overlapRemediationTargetId == nil)+    }++    @Test+    func saveSurfacesBandValidationErrors() async throws {+        let cases: [PricingValidationReason] = [+            .invertedDates, .bandWindowInvalid, .bandOverlap,+            .multipleFreeBands, .savingsRateMissing, .noRatedBand, .legacyShape+        ]+        for reason in cases {+            let (viewModel, apiClient) = makeViewModelAndAPI()+            apiClient.nextCreateError = .pricingValidation(reason)+            viewModel.beginCreate()+            viewModel.draft = makeDraft()+            try? await viewModel.save()+            #expect(viewModel.lastValidationError == reason, "\(reason)")+        }     } +    // MARK: - Succession (AC 6.3 / 6.5)+     @Test-    func remediateClosesOpenEndedAndCreatesNew() async throws {+    func remediationEndsTheCurrentPlanOnTheSuccessorsStartDate() async throws {         let (viewModel, apiClient) = makeViewModelAndAPI()-        let open = makePeriod(id: "pp-open", start: "2026-01-01", end: nil)-        apiClient.periodsToReturn = [open]+        let open = makePlan(id: "pp-open", start: "2026-01-01", end: nil)+        apiClient.plansToReturn = [open]         await viewModel.refresh()-        let closing = makePeriod(id: "pp-open", start: "2026-01-01", end: "2026-07-31")-        let newOpen = makePeriod(id: "pp-new", start: "2026-08-01", end: nil)-        apiClient.replaceOpenEndedResult = ReplaceOpenEndedResult(closing: closing, newPeriod: newOpen)-        apiClient.nextCreateError = nil+        // The closing row's exclusive end date IS the successor's start date —+        // no ±1 arithmetic anywhere (AC 2.2).+        let closing = makePlan(id: "pp-open", start: "2026-01-01", end: "2026-08-01")+        let newOpen = makeTouPlan(id: "pp-new", start: "2026-08-01", end: nil)+        apiClient.replaceOpenEndedResult = ReplaceOpenEndedResult(closing: closing, newPlan: newOpen)          viewModel.beginCreate()-        viewModel.draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: nil,-            peakRate: 0.30,-            feedInRate: 0.06,-            offPeakSavingsRate: 0.12-        )-        // Simulate the overlap error setting the remediation target.+        viewModel.draft = makeDraft()         apiClient.nextCreateError = .pricingValidation(.overlap(openEndedId: "pp-open"))         try? await viewModel.save()         #expect(viewModel.overlapRemediationTargetId == "pp-open")          apiClient.nextCreateError = nil         try await viewModel.remediateOverlap()-        // After remediation, editor dismisses and remediation target clears.++        let sentClosingId = try #require(apiClient.lastReplaceClosingId)+        #expect(sentClosingId == "pp-open")+        let sentDraft = try #require(apiClient.lastReplaceDraft)+        #expect(sentDraft.startDate == "2026-08-01")         #expect(viewModel.overlapRemediationTargetId == nil)         #expect(!viewModel.isEditorPresented)+        #expect(viewModel.plans.first(where: { $0.id == "pp-open" })?.endDate == "2026-08-01")+    }++    @Test+    func remediationCopyUsesSwitchDayPhrasing() {+        // AC 6.5: the predecessor now ends ON the switch date, not the day+        // before it, so the affordance must not say "the day before".+        let copy = PricingViewModel.remediationFooter(startDate: "2026-08-01")+        #expect(copy.contains("2026-08-01"))+        #expect(!copy.lowercased().contains("day before"))+    }++    @Test+    func remediationSurfacesLegacyShapeRejection() async throws {+        let (viewModel, apiClient) = makeViewModelAndAPI()+        let open = makePlan(id: "pp-open", start: "2026-01-01", end: nil)+        apiClient.plansToReturn = [open]+        await viewModel.refresh()++        viewModel.beginCreate()+        viewModel.draft = makeDraft()+        apiClient.nextCreateError = .pricingValidation(.overlap(openEndedId: "pp-open"))+        try? await viewModel.save()++        apiClient.nextReplaceError = .pricingValidation(.legacyShape)+        try? await viewModel.remediateOverlap()+        #expect(viewModel.lastValidationError == .legacyShape)+        #expect(viewModel.isEditorPresented, "a failed remediation keeps the editor open")     }      @Test     func clearErrorDismissesBanner() {         let (viewModel, _) = makeViewModelAndAPI()         viewModel.beginCreate()-        // Manually push an error into the service.         viewModel.service.bind(apiClient: TestPricingAPIClient())         viewModel.clearError()         #expect(viewModel.lastValidationError == nil)     } -    // MARK: - helpers+    // MARK: - Helpers      private func makeViewModelAndAPI() -> (PricingViewModel, TestPricingAPIClient) {         let apiClient = TestPricingAPIClient()@@ -180,19 +292,51 @@ struct PricingViewModelTests {         return (PricingViewModel(service: service), apiClient)     } -    private func makePeriod(+    private func makeDraft() -> PricingPlanDraft {+        PricingPlanDraft(+            startDate: "2026-08-01",+            endDate: nil,+            defaultRate: 0.35,+            windows: [+                PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+                PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)+            ],+            feedInRate: 0.06,+            savingsReferenceRate: 0.35+        )+    }++    private func makePlan(         id: String,         start: String,         end: String?,-        peak: Double = 0.30-    ) -> PricingPeriod {-        PricingPeriod(+        defaultRate: Double = 0.30+    ) -> PricingPlan {+        PricingPlan(             id: id,             startDate: start,             endDate: end,-            peakRate: peak,+            defaultRate: defaultRate,+            windows: [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)],             feedInRate: 0.05,-            offPeakSavingsRate: 0.12,+            savingsReferenceRate: 0.12,+            createdAt: Date(timeIntervalSince1970: 1),+            updatedAt: Date(timeIntervalSince1970: 1)+        )+    }++    private func makeTouPlan(id: String, start: String, end: String?) -> PricingPlan {+        PricingPlan(+            id: id,+            startDate: start,+            endDate: end,+            defaultRate: 0.35,+            windows: [+                PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+                PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)+            ],+            feedInRate: 0.05,+            savingsReferenceRate: 0.35,             createdAt: Date(timeIntervalSince1970: 1),             updatedAt: Date(timeIntervalSince1970: 1)         )@@ -203,9 +347,13 @@ struct PricingViewModelTests {  @MainActor final class TestPricingAPIClient: FluxAPIClient, @unchecked Sendable {-    var periodsToReturn: [PricingPeriod] = []+    var plansToReturn: [PricingPlan] = []     var nextCreateError: FluxAPIError?+    var nextReplaceError: FluxAPIError?     var replaceOpenEndedResult: ReplaceOpenEndedResult?+    private(set) var lastCreatedDraft: PricingPlanDraft?+    private(set) var lastReplaceClosingId: String?+    private(set) var lastReplaceDraft: PricingPlanDraft?      nonisolated func fetchStatus() async throws -> StatusResponse {         StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil, note: nil)@@ -218,55 +366,65 @@ final class TestPricingAPIClient: FluxAPIClient, @unchecked Sendable {         NoteResponse(date: date, text: "", updatedAt: nil)     } -    func fetchPricing() async throws -> [PricingPeriod] { periodsToReturn }+    func fetchPricing() async throws -> [PricingPlan] { plansToReturn } -    func createPricing(_ draft: PricingPeriodDraft) async throws -> PricingPeriod {+    func createPricing(_ draft: PricingPlanDraft) async throws -> PricingPlan {+        lastCreatedDraft = draft         if let err = nextCreateError {             nextCreateError = nil             throw err         }         let now = Date()-        let period = PricingPeriod(+        let plan = PricingPlan(             id: "new-\(UUID().uuidString)",             startDate: draft.startDate,             endDate: draft.endDate,-            peakRate: draft.peakRate,+            defaultRate: draft.defaultRate,+            windows: draft.windows,             feedInRate: draft.feedInRate,-            offPeakSavingsRate: draft.offPeakSavingsRate,+            savingsReferenceRate: draft.savingsReferenceRate,             createdAt: now, updatedAt: now         )-        periodsToReturn.append(period)-        return period+        plansToReturn.append(plan)+        return plan     } -    func updatePricing(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod {-        guard let idx = periodsToReturn.firstIndex(where: { $0.id == id }) else {+    func updatePricing(id: String, _ draft: PricingPlanDraft) async throws -> PricingPlan {+        guard let idx = plansToReturn.firstIndex(where: { $0.id == id }) else {             throw FluxAPIError.notFound         }         let now = Date()-        let period = PricingPeriod(+        let plan = PricingPlan(             id: id,             startDate: draft.startDate,             endDate: draft.endDate,-            peakRate: draft.peakRate,+            defaultRate: draft.defaultRate,+            windows: draft.windows,             feedInRate: draft.feedInRate,-            offPeakSavingsRate: draft.offPeakSavingsRate,-            createdAt: periodsToReturn[idx].createdAt,+            savingsReferenceRate: draft.savingsReferenceRate,+            createdAt: plansToReturn[idx].createdAt,             updatedAt: now         )-        periodsToReturn[idx] = period-        return period+        plansToReturn[idx] = plan+        return plan     }      func deletePricing(id: String) async throws {-        periodsToReturn.removeAll { $0.id == id }+        plansToReturn.removeAll { $0.id == id }     }      func replaceOpenEndedPricing(-        closingId _: String,-        with _: PricingPeriodDraft+        closingId: String,+        with draft: PricingPlanDraft     ) async throws -> ReplaceOpenEndedResult {-        if let result = replaceOpenEndedResult { return result }-        throw FluxAPIError.serverError+        lastReplaceClosingId = closingId+        lastReplaceDraft = draft+        if let err = nextReplaceError {+            nextReplaceError = nil+            throw err+        }+        guard let result = replaceOpenEndedResult else { throw FluxAPIError.serverError }+        return result     } }+// swiftlint:enable file_length type_body_length
Flux/FluxWidgets/Views/PowerTrioColumns.swift Modified +6 / -4
diff --git a/Flux/FluxWidgets/Views/PowerTrioColumns.swift b/Flux/FluxWidgets/Views/PowerTrioColumns.swiftindex e6d7e1c..5d53105 100644--- a/Flux/FluxWidgets/Views/PowerTrioColumns.swift+++ b/Flux/FluxWidgets/Views/PowerTrioColumns.swift@@ -7,12 +7,14 @@ struct PowerTrioColumns: View {     var spacing: CGFloat = 4     var tight: Bool = false -    private var offpeakStart: String {-        entry.offpeak?.windowStart ?? OffpeakData.defaultWindowStart+    /// A day with no free window has none — never substitute a default, which+    /// would falsely render the legacy 11:00–14:00 band (Q35).+    private var offpeakStart: String? {+        entry.offpeak?.windowStart     } -    private var offpeakEnd: String {-        entry.offpeak?.windowEnd ?? OffpeakData.defaultWindowEnd+    private var offpeakEnd: String? {+        entry.offpeak?.windowEnd     }      var body: some View {
Flux/FluxWidgets/Views/Shared/StatusEntry+WidgetColors.swift Modified +5 / -6
diff --git a/Flux/FluxWidgets/Views/Shared/StatusEntry+WidgetColors.swift b/Flux/FluxWidgets/Views/Shared/StatusEntry+WidgetColors.swiftindex a04465e..119c924 100644--- a/Flux/FluxWidgets/Views/Shared/StatusEntry+WidgetColors.swift+++ b/Flux/FluxWidgets/Views/Shared/StatusEntry+WidgetColors.swift@@ -14,13 +14,13 @@ extension StatusEntry {      var gridTintColor: Color {         if staleness == .offline { return .secondary }-        let windowStart = offpeak?.windowStart ?? OffpeakData.defaultWindowStart-        let windowEnd = offpeak?.windowEnd ?? OffpeakData.defaultWindowEnd+        // A day with no free window has none — never substitute a default,+        // which would falsely paint the legacy 11:00–14:00 band (Q35).         return GridColor.forGrid(             pgrid: pgrid,             pgridSustained: live?.pgridSustained ?? false,-            offpeakWindowStart: windowStart,-            offpeakWindowEnd: windowEnd,+            offpeakWindowStart: offpeak?.windowStart,+            offpeakWindowEnd: offpeak?.windowEnd,             now: date         ).color     }@@ -51,8 +51,7 @@ extension StatusEntry {         // Only escalate when the cutoff is actually close — distant predictions         // would otherwise paint the ring orange all afternoon on a normal day.         if cutoff.timeIntervalSince(date) > 6 * 60 * 60 { return nil }-        let windowStart = offpeak?.windowStart ?? OffpeakData.defaultWindowStart-        let tier = CutoffTimeColor.forCutoff(cutoff, offpeakWindowStart: windowStart, now: date)+        let tier = CutoffTimeColor.forCutoff(cutoff, offpeakWindowStart: offpeak?.windowStart, now: date)         switch tier {         case .red, .orange, .amber:             return tier.color
Flux/FluxWidgets/WidgetFixtures.swift Modified +2 / -2
diff --git a/Flux/FluxWidgets/WidgetFixtures.swift b/Flux/FluxWidgets/WidgetFixtures.swiftindex 42ed5cf..37b777e 100644--- a/Flux/FluxWidgets/WidgetFixtures.swift+++ b/Flux/FluxWidgets/WidgetFixtures.swift@@ -36,8 +36,8 @@ enum WidgetFixtures {                     estimatedCutoffTime: "2026-04-20T17:12:00Z"                 ),                 offpeak: OffpeakData(-                    windowStart: OffpeakData.defaultWindowStart,-                    windowEnd: OffpeakData.defaultWindowEnd,+                    windowStart: "11:00",+                    windowEnd: "14:00",                     gridUsageKwh: 1.2,                     solarKwh: nil,                     batteryChargeKwh: nil,
Flux/Packages/FluxCore/Sources/FluxCore/Helpers/CutoffTimeColor.swift Modified +6 / -2
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/CutoffTimeColor.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/CutoffTimeColor.swiftindex 7698dcd..b2e5241 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/CutoffTimeColor.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/CutoffTimeColor.swift@@ -1,16 +1,20 @@ import Foundation  public enum CutoffTimeColor {+    /// The window start is optional because a day whose plan has no free band+    /// has no window to run out of charge before (Q35). Absent takes the same+    /// path as an unparseable window: no escalation beyond the two-hour rule.     public static func forCutoff(         _ cutoffTime: Date,-        offpeakWindowStart: String,+        offpeakWindowStart: String?,         now: Date = .now     ) -> ColorTier {         if cutoffTime.timeIntervalSince(now) < 2 * 60 * 60 {             return .red         } -        guard let offpeakStart = DateFormatting.parseWindowTime(offpeakWindowStart, on: now) else {+        guard let offpeakWindowStart,+              let offpeakStart = DateFormatting.parseWindowTime(offpeakWindowStart, on: now) else {             return .normal         } 
Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift Modified +16 / -2
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swiftindex 833afcf..e6e81c0 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/DateFormatting.swift@@ -79,16 +79,30 @@ public enum DateFormatting {         dayFormatter.string(from: date)     } +    /// Parses an `"HH:MM"` band boundary onto `date`'s Sydney calendar day.+    ///+    /// `"24:00"` resolves to the start of the following day, matching the+    /// end-of-day sentinel `plan.ParseBandTime` and `PlanWindow.parseBandTime`+    /// use. A plan's free band may legitimately run to midnight, and without+    /// this the window would fail to parse and every consumer would treat the+    /// day as having no free window at all.     public static func parseWindowTime(_ timeString: String, on date: Date = .now) -> Date? {         let parts = timeString.split(separator: ":", omittingEmptySubsequences: false)         guard parts.count == 2,               let hour = Int(parts[0]),               let minute = Int(parts[1]),-              (0 ... 23).contains(hour),-              (0 ... 59).contains(minute)+              (0 ... 24).contains(hour),+              (0 ... 59).contains(minute),+              hour < 24 || minute == 0         else {             return nil         }+        if hour == 24 {+            // Calendar arithmetic, not +86400: the day after a DST transition+            // is 23 or 25 hours long.+            let dayStart = sydneyCalendar.startOfDay(for: date)+            return sydneyCalendar.date(byAdding: .day, value: 1, to: dayStart)+        }         return sydneyCalendar.date(bySettingHour: hour, minute: minute, second: 0, of: date)     } 
Flux/Packages/FluxCore/Sources/FluxCore/Helpers/GridColor.swift Modified +14 / -5
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/GridColor.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/GridColor.swiftindex 2499a70..b88dac7 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/GridColor.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Helpers/GridColor.swift@@ -1,20 +1,29 @@ import Foundation  public enum GridColor {+    /// The window is optional because a day whose plan has no free band — or+    /// which no plan prices — genuinely has no off-peak window (Q35). Absent+    /// is treated as "outside the window", the same as any other time of day;+    /// substituting a default would falsely excuse sustained import.     public static func forGrid(         pgrid: Double,         pgridSustained: Bool,-        offpeakWindowStart: String,-        offpeakWindowEnd: String,+        offpeakWindowStart: String?,+        offpeakWindowEnd: String?,         now: Date = .now     ) -> ColorTier {         if pgrid < 0 {             return .green         } -        if pgrid > 500 &&-            pgridSustained &&-            !DateFormatting.isInOffpeakWindow(start: offpeakWindowStart, end: offpeakWindowEnd, now: now) {+        let inOffpeak: Bool+        if let offpeakWindowStart, let offpeakWindowEnd {+            inOffpeak = DateFormatting.isInOffpeakWindow(start: offpeakWindowStart, end: offpeakWindowEnd, now: now)+        } else {+            inOffpeak = false+        }++        if pgrid > 500 && pgridSustained && !inOffpeak {             return .red         } 
Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift Modified +54 / -8
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swiftindex e0f2b41..610e1f8 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift@@ -112,9 +112,6 @@ public struct RollingAvg: Codable, Sendable { }  public struct OffpeakData: Codable, Sendable {-    public static let defaultWindowStart = "11:00"-    public static let defaultWindowEnd = "14:00"-     /// Lifecycle of the off-peak record for the day. `pending` covers the     /// in-progress window where deltas are projected against today's     /// running totals; `complete` is the final post-window record.@@ -123,8 +120,13 @@ public struct OffpeakData: Codable, Sendable {         case complete     } -    public let windowStart: String-    public let windowEnd: String+    /// The free window of the plan pricing the day, or `nil` when that plan+    /// has no free band. Clients must render `nil` as "no window" — never+    /// substitute a fixed window, which would falsely show the legacy+    /// 11:00–14:00 on a day that has none (Q35). The whole `offpeak` object is+    /// itself nullable, for a day no plan prices at all.+    public let windowStart: String?+    public let windowEnd: String?     public let status: Status?     public let gridUsageKwh: Double?     public let solarKwh: Double?@@ -138,8 +140,8 @@ public struct OffpeakData: Codable, Sendable {     public let projectedEndSoc: Double?      public init(-        windowStart: String,-        windowEnd: String,+        windowStart: String?,+        windowEnd: String?,         status: Status? = nil,         gridUsageKwh: Double?,         solarKwh: Double?,@@ -208,6 +210,18 @@ public struct DayEnergy: Codable, Sendable, Identifiable {     /// fall back to the `eInput − offpeakGridImportKwh` residual at the call     /// site (see `HistoryDerivedState.gridEntry`).     public let peakGridImportKwh: Double?+    /// The day's rated-band import split, absent when the day is unpriced or+    /// its split is unavailable (AC 3.6). Only rated bands appear — the free+    /// band's import is `offpeakGridImportKwh`, which owns it (Q31).+    public let bandImports: [BandImport]?+    /// Geometry and provenance of the off-peak row `offpeakGridImportKwh` came+    /// from, so a later plan-window edit is detectable as a mismatch rather+    /// than silently mispricing the day. Absent on pre-feature rows, which can+    /// only have been computed under 11:00–14:00.+    public let offpeakWindowStart: String?+    public let offpeakWindowEnd: String?+    public let offpeakIntegratedAt: String?+    public let offpeakSampleCount: Int?     public let note: String?      // Derived per-day stats, populated by the daily-derived-stats backend pass@@ -233,6 +247,11 @@ public struct DayEnergy: Codable, Sendable, Identifiable {         offpeakGridImportKwh: Double? = nil,         offpeakGridExportKwh: Double? = nil,         peakGridImportKwh: Double? = nil,+        bandImports: [BandImport]? = nil,+        offpeakWindowStart: String? = nil,+        offpeakWindowEnd: String? = nil,+        offpeakIntegratedAt: String? = nil,+        offpeakSampleCount: Int? = nil,         note: String? = nil,         dailyUsage: DailyUsage? = nil,         socLow: Double? = nil,@@ -248,6 +267,11 @@ public struct DayEnergy: Codable, Sendable, Identifiable {         self.offpeakGridImportKwh = offpeakGridImportKwh         self.offpeakGridExportKwh = offpeakGridExportKwh         self.peakGridImportKwh = peakGridImportKwh+        self.bandImports = bandImports+        self.offpeakWindowStart = offpeakWindowStart+        self.offpeakWindowEnd = offpeakWindowEnd+        self.offpeakIntegratedAt = offpeakIntegratedAt+        self.offpeakSampleCount = offpeakSampleCount         self.note = note         self.dailyUsage = dailyUsage         self.socLow = socLow@@ -406,6 +430,18 @@ public struct DaySummary: Codable, Sendable {     /// Absent for today and for days the integration's usability gate failed;     /// callers fall back to the `eInput − offpeakGridImportKwh` residual.     public let peakGridImportKwh: Double?+    /// The day's rated-band import split, absent when the day is unpriced or+    /// its split is unavailable (AC 3.6). Only rated bands appear — the free+    /// band's import is `offpeakGridImportKwh`, which owns it (Q31).+    public let bandImports: [BandImport]?+    /// Geometry and provenance of the off-peak row `offpeakGridImportKwh` came+    /// from, so a later plan-window edit is detectable as a mismatch rather+    /// than silently mispricing the day. Absent on pre-feature rows, which can+    /// only have been computed under 11:00–14:00.+    public let offpeakWindowStart: String?+    public let offpeakWindowEnd: String?+    public let offpeakIntegratedAt: String?+    public let offpeakSampleCount: Int?      public init(         epv: Double?,@@ -417,7 +453,12 @@ public struct DaySummary: Codable, Sendable {         socLowTime: String?,         offpeakGridImportKwh: Double? = nil,         offpeakGridExportKwh: Double? = nil,-        peakGridImportKwh: Double? = nil+        peakGridImportKwh: Double? = nil,+        bandImports: [BandImport]? = nil,+        offpeakWindowStart: String? = nil,+        offpeakWindowEnd: String? = nil,+        offpeakIntegratedAt: String? = nil,+        offpeakSampleCount: Int? = nil     ) {         self.epv = epv         self.eInput = eInput@@ -429,6 +470,11 @@ public struct DaySummary: Codable, Sendable {         self.offpeakGridImportKwh = offpeakGridImportKwh         self.offpeakGridExportKwh = offpeakGridExportKwh         self.peakGridImportKwh = peakGridImportKwh+        self.bandImports = bandImports+        self.offpeakWindowStart = offpeakWindowStart+        self.offpeakWindowEnd = offpeakWindowEnd+        self.offpeakIntegratedAt = offpeakIntegratedAt+        self.offpeakSampleCount = offpeakSampleCount     } } 
Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift Modified +27 / -6
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swiftindex 2b397d4..3f3df17 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift@@ -13,9 +13,9 @@ public enum FluxAPIError: Error, Sendable, Equatable {     case pricingValidation(PricingValidationReason) } -/// Validation failure codes returned by the pricing endpoints (Requirement 1-/// and AC 2.3). Mirrors the server-side error codes verbatim so the editor-/// can map each one to inline field-level feedback.+/// Validation failure codes returned by the pricing endpoints (AC 7.2).+/// Mirrors the server-side error codes verbatim so the editor can map each one+/// to inline field-level feedback. public enum PricingValidationReason: Error, Sendable, Equatable {     case invertedDates     case overlap(openEndedId: String?)@@ -25,6 +25,15 @@ public enum PricingValidationReason: Error, Sendable, Equatable {     /// Returned as HTTP 409 when a concurrent writer raced this one; the     /// editor refetches the list and retries.     case concurrentWrite+    /// Band rules (time-of-use-pricing).+    case bandWindowInvalid+    case bandOverlap+    case multipleFreeBands+    case savingsRateMissing+    case noRatedBand+    /// A pre-migration three-rate payload, or a `replace-open-ended` whose+    /// closing row is still the legacy shape (AC 7.3 / Q32).+    case legacyShape }  extension FluxAPIError {@@ -74,17 +83,29 @@ extension PricingValidationReason {     public var message: String {         switch self {         case .invertedDates:-            return "End date must not be before the start date."+            return "The end date must be after the start date."         case .overlap:-            return "This period overlaps an existing one. Close the previous period first."+            return "This plan overlaps an existing one. Close the previous plan first."         case .ratePrecision:             return "Rates must use at most four decimal places."         case .rateOutOfRange:             return "Each rate must be between $0.00 and $10.00 per kWh."         case .secondOpenEnded:-            return "Only one open-ended pricing period is allowed at a time."+            return "Only one open-ended pricing plan is allowed at a time."         case .concurrentWrite:             return "Another change was just applied. Try again."+        case .bandWindowInvalid:+            return "Each window needs a start before its end, between 00:00 and 24:00."+        case .bandOverlap:+            return "Windows must not overlap each other."+        case .multipleFreeBands:+            return "A plan can have at most one free window."+        case .savingsRateMissing:+            return "A plan with a free window needs a savings reference rate."+        case .noRatedBand:+            return "A free window covering the whole day leaves nothing to price."+        case .legacyShape:+            return "This plan is still in the old three-rate format. Run the pricing migration first."         }     } }
Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift Modified +8 / -8
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swiftindex 98bd0f7..bd22975 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift@@ -28,13 +28,13 @@ public protocol FluxAPIClient: Sendable {     func deleteRule(deviceId: String, ruleId: String) async throws      // Pricing — daily-costs spec.-    func fetchPricing() async throws -> [PricingPeriod]-    func createPricing(_ draft: PricingPeriodDraft) async throws -> PricingPeriod-    func updatePricing(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod+    func fetchPricing() async throws -> [PricingPlan]+    func createPricing(_ draft: PricingPlanDraft) async throws -> PricingPlan+    func updatePricing(id: String, _ draft: PricingPlanDraft) async throws -> PricingPlan     func deletePricing(id: String) async throws     func replaceOpenEndedPricing(         closingId: String,-        with draft: PricingPeriodDraft+        with draft: PricingPlanDraft     ) async throws -> ReplaceOpenEndedResult } @@ -63,15 +63,15 @@ public extension FluxAPIClient {         throw FluxAPIError.notConfigured     } -    func fetchPricing() async throws -> [PricingPeriod] {+    func fetchPricing() async throws -> [PricingPlan] {         throw FluxAPIError.notConfigured     } -    func createPricing(_: PricingPeriodDraft) async throws -> PricingPeriod {+    func createPricing(_: PricingPlanDraft) async throws -> PricingPlan {         throw FluxAPIError.notConfigured     } -    func updatePricing(id _: String, _: PricingPeriodDraft) async throws -> PricingPeriod {+    func updatePricing(id _: String, _: PricingPlanDraft) async throws -> PricingPlan {         throw FluxAPIError.notConfigured     } @@ -81,7 +81,7 @@ public extension FluxAPIClient {      func replaceOpenEndedPricing(         closingId _: String,-        with _: PricingPeriodDraft+        with _: PricingPlanDraft     ) async throws -> ReplaceOpenEndedResult {         throw FluxAPIError.notConfigured     }
Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift Modified +1 / -94
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swiftindex 41ea37b..3ffd7ca 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift@@ -4,7 +4,7 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable {     private let session: URLSession     private let baseURL: URL     private let tokenProvider: @Sendable () -> String?-    private let decoder: JSONDecoder+    let decoder: JSONDecoder  // internal: used by the pricing extension's own file     let encoder: JSONEncoder  // internal: used by the simulation extension's own file      private static let noCacheSession: URLSession = {@@ -305,96 +305,3 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable {     }  }--// MARK: - Pricing (daily-costs spec)--extension URLSessionAPIClient {-    public func fetchPricing() async throws -> [PricingPeriod] {-        let response: PricingListResponse = try await performRequest(path: "pricing", queryItems: [])-        return response.pricing-    }--    public func createPricing(_ draft: PricingPeriodDraft) async throws -> PricingPeriod {-        let body = try encoder.encode(draft)-        return try await performRequest(path: "pricing", queryItems: [], method: "POST", body: body)-    }--    public func updatePricing(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod {-        let body = try encoder.encode(draft)-        return try await performRequest(path: "pricing/\(id)", queryItems: [], method: "PUT", body: body)-    }--    public func deletePricing(id: String) async throws {-        let _: EmptyPricingResponse = try await performRequest(-            path: "pricing/\(id)",-            queryItems: [],-            method: "DELETE"-        )-    }--    public func replaceOpenEndedPricing(-        closingId: String,-        with draft: PricingPeriodDraft-    ) async throws -> ReplaceOpenEndedResult {-        let payload = ReplaceOpenEndedPayload(closingPricingId: closingId, newPeriod: draft)-        let body = try encoder.encode(payload)-        let response: PricingListResponse = try await performRequest(-            path: "pricing/replace-open-ended",-            queryItems: [],-            method: "POST",-            body: body-        )-        guard response.pricing.count == 2 else {-            throw FluxAPIError.decodingError("replace-open-ended expected 2 rows, got \(response.pricing.count)")-        }-        // Match by id rather than position so a server-side reorder-        // (e.g. start-date sort) can't swap closing and new on the wire.-        guard let closing = response.pricing.first(where: { $0.id == closingId }) else {-            throw FluxAPIError.decodingError("replace-open-ended response missing row with id \(closingId)")-        }-        guard let newPeriod = response.pricing.first(where: { $0.id != closingId }) else {-            throw FluxAPIError.decodingError("replace-open-ended response: both rows share id \(closingId)")-        }-        return ReplaceOpenEndedResult(closing: closing, newPeriod: newPeriod)-    }--    fileprivate func parsePricingValidationReason(from data: Data) -> PricingValidationReason? {-        guard let payload = try? decoder.decode(PricingErrorResponse.self, from: data) else {-            return nil-        }-        switch payload.error {-        case "inverted_dates":-            return .invertedDates-        case "overlap":-            return .overlap(openEndedId: payload.openEndedId)-        case "rate_precision":-            return .ratePrecision-        case "rate_out_of_range":-            return .rateOutOfRange-        case "second_open_ended":-            return .secondOpenEnded-        case "concurrent_open_ended_write":-            return .concurrentWrite-        default:-            return nil-        }-    }--    fileprivate struct PricingErrorResponse: Decodable {-        let error: String-        let openEndedId: String?-    }--    fileprivate struct PricingListResponse: Decodable {-        let pricing: [PricingPeriod]-    }--    fileprivate struct ReplaceOpenEndedPayload: Encodable {-        let closingPricingId: String-        let newPeriod: PricingPeriodDraft-    }--    fileprivate struct EmptyPricingResponse: Decodable {-        init(from _: Decoder) throws {}-    }-}
Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+Pricing.swift Added +102 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+Pricing.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+Pricing.swiftnew file mode 100644index 0000000..63fa1f8--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient+Pricing.swift@@ -0,0 +1,102 @@+import Foundation++// MARK: - Pricing (daily-costs, time-of-use-pricing specs)++extension URLSessionAPIClient {+    public func fetchPricing() async throws -> [PricingPlan] {+        let response: PricingListResponse = try await performRequest(path: "pricing", queryItems: [])+        return response.pricing+    }++    public func createPricing(_ draft: PricingPlanDraft) async throws -> PricingPlan {+        let body = try encoder.encode(draft)+        return try await performRequest(path: "pricing", queryItems: [], method: "POST", body: body)+    }++    public func updatePricing(id: String, _ draft: PricingPlanDraft) async throws -> PricingPlan {+        let body = try encoder.encode(draft)+        return try await performRequest(path: "pricing/\(id)", queryItems: [], method: "PUT", body: body)+    }++    public func deletePricing(id: String) async throws {+        let _: EmptyPricingResponse = try await performRequest(+            path: "pricing/\(id)",+            queryItems: [],+            method: "DELETE"+        )+    }++    public func replaceOpenEndedPricing(+        closingId: String,+        with draft: PricingPlanDraft+    ) async throws -> ReplaceOpenEndedResult {+        let payload = ReplaceOpenEndedPayload(closingPricingId: closingId, newPeriod: draft)+        let body = try encoder.encode(payload)+        let response: PricingListResponse = try await performRequest(+            path: "pricing/replace-open-ended",+            queryItems: [],+            method: "POST",+            body: body+        )+        guard response.pricing.count == 2 else {+            throw FluxAPIError.decodingError("replace-open-ended expected 2 rows, got \(response.pricing.count)")+        }+        // Match by id rather than position so a server-side reorder+        // (e.g. start-date sort) can't swap closing and new on the wire.+        guard let closing = response.pricing.first(where: { $0.id == closingId }) else {+            throw FluxAPIError.decodingError("replace-open-ended response missing row with id \(closingId)")+        }+        guard let newPlan = response.pricing.first(where: { $0.id != closingId }) else {+            throw FluxAPIError.decodingError("replace-open-ended response: both rows share id \(closingId)")+        }+        return ReplaceOpenEndedResult(closing: closing, newPlan: newPlan)+    }++    /// The server's `error` codes, mirrored one-for-one. `overlap` is the only+    /// one carrying a payload, so it is handled separately from the table.+    private static let validationReasonsByCode: [String: PricingValidationReason] = [+        "inverted_dates": .invertedDates,+        "rate_precision": .ratePrecision,+        "rate_out_of_range": .rateOutOfRange,+        "second_open_ended": .secondOpenEnded,+        "concurrent_open_ended_write": .concurrentWrite,+        "band_window_invalid": .bandWindowInvalid,+        "band_overlap": .bandOverlap,+        "multiple_free_bands": .multipleFreeBands,+        "savings_rate_missing": .savingsRateMissing,+        "no_rated_band": .noRatedBand,+        "legacy_shape": .legacyShape+    ]++    func parsePricingValidationReason(from data: Data) -> PricingValidationReason? {+        guard let payload = try? decoder.decode(PricingErrorResponse.self, from: data) else {+            return nil+        }+        if payload.error == "overlap" {+            return .overlap(openEndedId: payload.openEndedId)+        }+        return Self.validationReasonsByCode[payload.error]+    }++    struct PricingErrorResponse: Decodable {+        let error: String+        /// Populated only when the overlap offender is the unique open-ended+        /// plan — the id that powers the editor's one-tap remediation.+        let openEndedId: String?+    }++    struct PricingListResponse: Decodable {+        let pricing: [PricingPlan]+    }++    /// The request field name stays `newPeriod` — it is the server's wire+    /// contract, unchanged by the band rework.+    struct ReplaceOpenEndedPayload: Encodable {+        let closingPricingId: String+        let newPeriod: PricingPlanDraft+    }++    struct EmptyPricingResponse: Decodable {+        init(from _: Decoder) throws {}+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swift Modified +269 / -51
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swiftindex 967e275..0251685 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swift@@ -1,86 +1,304 @@ import Foundation +/// One rated band's stored grid import for a day. Each entry snapshots the+/// geometry it was captured under (Q23), so a later plan-window edit is+/// detectable as a mismatch rather than silently mispricing the day.+///+/// Only rated bands appear: the free band's import lives on the off-peak row,+/// which owns that quantity exclusively (Q31). Mirrors `plan.BandImport`.+public struct BandImport: Codable, Sendable, Equatable, Hashable {+    public let start: String+    public let end: String+    public let kwh: Double++    public init(start: String, end: String, kwh: Double) {+        self.start = start+        self.end = end+        self.kwh = kwh+    }+}++/// The off-peak row's contribution to costing — the only source of the free+/// band's kWh. Mirrors `plan.OffpeakRow`.+public struct OffpeakImport: Sendable, Equatable {+    /// The window every pre-feature off-peak row was integrated under. Rows+    /// predating the geometry snapshot carry no window, and this is the only+    /// window they can have had.+    public static let legacyWindowStart = "11:00"+    public static let legacyWindowEnd = "14:00"++    public let gridImportKwh: Double+    /// The geometry the row was integrated under; `nil` on a pre-feature row.+    public let windowStart: String?+    public let windowEnd: String?+    /// Integration provenance. A row integrated from readings but with no+    /// samples is a zero-delta artifact, not a measured zero.+    public let integratedAt: String?+    public let sampleCount: Int++    public init(+        gridImportKwh: Double,+        windowStart: String? = nil,+        windowEnd: String? = nil,+        integratedAt: String? = nil,+        sampleCount: Int = 0+    ) {+        self.gridImportKwh = gridImportKwh+        self.windowStart = windowStart+        self.windowEnd = windowEnd+        self.integratedAt = integratedAt+        self.sampleCount = sampleCount+    }++    /// Whether the row's import is a real measurement. Rows predating the+    /// integration path (no `integratedAt`) are snapshot deltas and stay usable.+    public var isUsable: Bool {+        integratedAt == nil || sampleCount > 0+    }++    /// The window the row was integrated under, substituting the pre-feature+    /// window when the row carries no snapshot.+    public var geometry: (start: String, end: String) {+        guard let windowStart, let windowEnd, !windowStart.isEmpty, !windowEnd.isEmpty else {+            return (Self.legacyWindowStart, Self.legacyWindowEnd)+        }+        return (windowStart, windowEnd)+    }+}++/// One day's stored energy, as cost resolution sees it. Optionals distinguish+/// "never recorded" from a measured zero — the distinction the single-rate+/// formula turns on. Mirrors `plan.DayEnergy`.+public struct DayCostInputs: Sendable, Equatable {+    public let eInput: Double?+    public let eOutput: Double?+    public let peakGridImportKwh: Double?+    public let offpeak: OffpeakImport?+    public let bandImports: [BandImport]?++    public init(+        eInput: Double?,+        eOutput: Double?,+        peakGridImportKwh: Double?,+        offpeak: OffpeakImport?,+        bandImports: [BandImport]?+    ) {+        self.eInput = eInput+        self.eOutput = eOutput+        self.peakGridImportKwh = peakGridImportKwh+        self.offpeak = offpeak+        self.bandImports = bandImports+    }+}++/// Which resolution path produced a `DayCosts`. Exposed so tests can assert on+/// the path, not just the number. Mirrors `plan.Tier`.+public enum CostTier: Int, Sendable, Equatable {+    /// Each rated band priced at its own rate from the stored split.+    case banded = 1+    /// The pre-band formula, applicable whenever the plan's rated segments+    /// share one rate — which every migrated legacy plan does.+    case singleRate = 2+    /// All import at the plan's highest rate with no savings (AC 3.6).+    /// Reachable only for multi-rate plans.+    case fallback = 3+}+ /// Per-day cost breakdown rendered on the Day Detail costs card. Computed on-/// read from the stored kWh values and the current pricing list — there is-/// no persisted snapshot (Decision 8).+/// read from the stored kWh values and the current plan list — there is no+/// persisted snapshot (Decision 8). public struct DayCosts: Equatable, Sendable {     public let peakImportsCost: Double     public let solarFeedInIncome: Double     public let net: Double     public let offPeakSavings: Double+    public let tier: CostTier      public init(         peakImportsCost: Double,         solarFeedInIncome: Double,         net: Double,-        offPeakSavings: Double+        offPeakSavings: Double,+        tier: CostTier = .singleRate     ) {         self.peakImportsCost = peakImportsCost         self.solarFeedInIncome = solarFeedInIncome         self.net = net         self.offPeakSavings = offPeakSavings+        self.tier = tier     } } -public extension DaySummary {-    /// Returns the cost breakdown for `date` if a pricing period covers it.-    /// Returns `nil` when no period covers `date` (AC 4.6).-    ///-    /// Zero kWh fields produce zero cost lines and do NOT make the day-    /// unpriced (Decision 18). When `offpeakGridImportKwh` is `nil`, all of-    /// `eInput` is billed as peak (Decision 23) and off-peak savings is `0`.-    ///-    /// Peak kWh prefers the server-computed `peakGridImportKwh` when present-    /// (peak-from-readings Decision 8); otherwise it falls back to the-    /// `eInput − offpeak` residual. Off-peak savings always price the measured-    /// off-peak kWh, so with the server peak the two no longer sum to `eInput`-    /// (they differ by ~1.5%, the shared sampling artifact) — each is priced on-    /// what was actually measured for that window.-    func costs(forDate date: String, in pricing: [PricingPeriod]) -> DayCosts? {-        guard let period = pricing.first(where: { $0.covers(date: date) }) else {-            return nil+public extension DayCosts {+    /// Resolves one day's costs under the plan pricing that day. Resolution+    /// order is banded → single-rate → fallback (Decision 6); the Go side runs+    /// the identical order in `plan.DayCosts`, pinned by the shared vectors in+    /// `internal/api/testdata/pricing_costs.json`.+    static func resolve(plan: PricingPlan, day: DayCostInputs) -> DayCosts {+        let feedIn = (day.eOutput ?? 0) * plan.feedInRate+        func finish(importCost: Double, savings: Double, tier: CostTier) -> DayCosts {+            DayCosts(+                peakImportsCost: importCost,+                solarFeedInIncome: feedIn,+                net: importCost - feedIn,+                offPeakSavings: savings,+                tier: tier+            )+        }++        let rated = plan.ratedSegments++        if let banded = bandedCosts(plan: plan, rated: rated, day: day) {+            return finish(importCost: banded.importCost, savings: banded.savings, tier: .banded)+        }+        if let rate = singleRate(of: rated) {+            let resolved = singleRateCosts(plan: plan, day: day, rate: rate)+            return finish(importCost: resolved.importCost, savings: resolved.savings, tier: .singleRate)+        }+        // AC 3.6: an unresolvable split prices everything at the highest rate+        // and shows no savings — the conservative overestimate every screen+        // must agree on.+        let highest = rated.map(\.rate).max() ?? 0+        return finish(importCost: (day.eInput ?? 0) * highest, savings: 0, tier: .fallback)+    }++    /// Prices the day from the stored split. Applies only when the split's+    /// geometry exactly matches the plan's rated segments AND the free band's+    /// import is resolvable — a partially known split is unavailable (AC 3.6),+    /// not partially used.+    private static func bandedCosts(+        plan: PricingPlan,+        rated: [PlanSegment],+        day: DayCostInputs+    ) -> (importCost: Double, savings: Double)? {+        guard let bands = day.bandImports, !rated.isEmpty, bands.count == rated.count else { return nil }++        var importCost = 0.0+        for (index, segment) in rated.enumerated() {+            guard bands[index].start == segment.start, bands[index].end == segment.end else { return nil }+            importCost += bands[index].kwh * segment.rate         } -        let solarKwh = eOutput ?? 0-        let peakKwh: Double-        let offPeakKwh: Double-        if let off = offpeakGridImportKwh {-            offPeakKwh = off-            peakKwh = peakGridImportKwh ?? max(0, (eInput ?? 0) - off)-        } else {-            offPeakKwh = 0-            peakKwh = peakGridImportKwh ?? (eInput ?? 0)+        guard let free = plan.freeWindow else {+            // No free band: the rated segments are the whole day and there is+            // nothing to value as savings.+            return (importCost, 0)         }+        guard let offpeak = day.offpeak, offpeak.isUsable else { return nil }+        let geometry = offpeak.geometry+        guard geometry.start == free.start, geometry.end == free.end else { return nil }+        guard let savingsRate = plan.savingsReferenceRate else { return (importCost, 0) }+        return (importCost, offpeak.gridImportKwh * savingsRate)+    }++    /// The pre-band formula, unchanged. Peak kWh prefers the server-computed+    /// value over the `eInput − off-peak` residual: the two differ by ~1.5% by+    /// design (a shared sampling artifact), and pricing the measured value is+    /// what keeps migrated history identical (Q30).+    private static func singleRateCosts(+        plan: PricingPlan,+        day: DayCostInputs,+        rate: Double+    ) -> (importCost: Double, savings: Double) {+        let total = day.eInput ?? 0+        guard let offpeak = day.offpeak else {+            return ((day.peakGridImportKwh ?? total) * rate, 0)+        }++        let off = offpeak.gridImportKwh+        let peak = day.peakGridImportKwh ?? max(0, total - off)+        let savings = plan.savingsReferenceRate.map { off * $0 } ?? 0+        return (peak * rate, savings)+    }++    /// The rate shared by every rated segment, or `nil` when they carry more+    /// than one — the only case that can reach the fallback tier.+    private static func singleRate(of rated: [PlanSegment]) -> Double? {+        guard let first = rated.first else { return nil }+        return rated.allSatisfy { $0.rate == first.rate } ? first.rate : nil+    }+} -        let peakCost = peakKwh * period.peakRate-        let feedIn = solarKwh * period.feedInRate-        let savings = offPeakKwh * period.offPeakSavingsRate-        return DayCosts(-            peakImportsCost: peakCost,-            solarFeedInIncome: feedIn,-            net: peakCost - feedIn,-            offPeakSavings: savings+public extension OffpeakImport {+    /// Reconstructs the off-peak row from the flat fields the read payloads+    /// carry. `/day` and `/history` describe the same day and the Data+    /// Consistency rule requires it to price identically on both, so the two+    /// rules that matter live here once: a day with no off-peak import has no+    /// row at all rather than a zero row — the distinction the single-rate+    /// formula turns on — and an absent sample count reads as zero samples.+    ///+    /// The surrounding `DayCostInputs` is still built per payload type:+    /// `DayEnergy.eInput` is non-optional where `DaySummary.eInput` is+    /// optional, so a shared protocol cannot cover the energy fields.+    static func from(+        gridImportKwh: Double?,+        windowStart: String?,+        windowEnd: String?,+        integratedAt: String?,+        sampleCount: Int?+    ) -> OffpeakImport? {+        gridImportKwh.map {+            OffpeakImport(+                gridImportKwh: $0,+                windowStart: windowStart,+                windowEnd: windowEnd,+                integratedAt: integratedAt,+                sampleCount: sampleCount ?? 0+            )+        }+    }+}++public extension DaySummary {+    /// Returns the cost breakdown for `date` if a plan covers it, or `nil` when+    /// none does (AC 2.7).+    ///+    /// Zero kWh fields produce zero cost lines and do NOT make the day+    /// unpriced (Decision 18).+    func costs(forDate date: String, in pricing: [PricingPlan]) -> DayCosts? {+        guard let plan = PricingPlan.plan(for: date, in: pricing) else { return nil }+        return DayCosts.resolve(plan: plan, day: costInputs)+    }++    /// The day's energy as cost resolution sees it.+    var costInputs: DayCostInputs {+        DayCostInputs(+            eInput: eInput,+            eOutput: eOutput,+            peakGridImportKwh: peakGridImportKwh,+            offpeak: .from(+                gridImportKwh: offpeakGridImportKwh,+                windowStart: offpeakWindowStart,+                windowEnd: offpeakWindowEnd,+                integratedAt: offpeakIntegratedAt,+                sampleCount: offpeakSampleCount+            ),+            bandImports: bandImports         )     } }  public extension DayEnergy {-    /// Convenience for History per-day costing. Forwards to the-    /// `DaySummary` extension using `self.date` and a transient `DaySummary`-    /// built from the day's fields.-    func costs(in pricing: [PricingPeriod]) -> DayCosts? {-        let summary = DaySummary(-            epv: epv,+    /// Convenience for History per-day costing, using `self.date` and the same+    /// resolution the Day Detail card uses so both screens agree (AC 3.4).+    func costs(in pricing: [PricingPlan]) -> DayCosts? {+        guard let plan = PricingPlan.plan(for: date, in: pricing) else { return nil }+        return DayCosts.resolve(plan: plan, day: costInputs)+    }++    var costInputs: DayCostInputs {+        DayCostInputs(             eInput: eInput,             eOutput: eOutput,-            eCharge: eCharge,-            eDischarge: eDischarge,-            socLow: socLow,-            socLowTime: socLowTime,-            offpeakGridImportKwh: offpeakGridImportKwh,-            offpeakGridExportKwh: offpeakGridExportKwh,-            peakGridImportKwh: peakGridImportKwh+            peakGridImportKwh: peakGridImportKwh,+            offpeak: .from(+                gridImportKwh: offpeakGridImportKwh,+                windowStart: offpeakWindowStart,+                windowEnd: offpeakWindowEnd,+                integratedAt: offpeakIntegratedAt,+                sampleCount: offpeakSampleCount+            ),+            bandImports: bandImports         )-        return summary.costs(forDate: date, in: pricing)     } }
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PeriodCosts.swift Modified +7 / -4
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PeriodCosts.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PeriodCosts.swiftindex dabedda..952ee58 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PeriodCosts.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PeriodCosts.swift@@ -31,10 +31,13 @@ public struct PeriodCosts: Equatable, Sendable {  public extension PeriodCosts {     /// Sums costs across `days`. Returns `nil` iff no day in `days` is a-    /// priced day, matching AC 5.4. The `totalDayCount` is the full range-    /// (including unpriced days) so the caller can render the-    /// `N of M days priced` caption from AC 5.3.-    static func compute(days: [DayEnergy], pricing: [PricingPeriod]) -> PeriodCosts? {+    /// priced day (AC 2.7). The `totalDayCount` is the full range (including+    /// unpriced days) so the caller can render the `N of M days priced`+    /// caption from AC 3.7.+    ///+    /// Each day resolves its own tier under the plan pricing it, so a range+    /// spanning a switch date sums days priced by different plans.+    static func compute(days: [DayEnergy], pricing: [PricingPlan]) -> PeriodCosts? {         guard !days.isEmpty else { return nil }          var peak: Double = 0
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPeriod.swift Deleted +0 / -58
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPeriodDraft.swift Deleted +0 / -113
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPlan.swift Added +234 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPlan.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPlan.swiftnew file mode 100644index 0000000..9771da2--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPlan.swift@@ -0,0 +1,234 @@+import Foundation++/// One exception to a plan's default rate: a half-open `[start, end)` slice of+/// the day that is either free or carries its own rate. Boundaries are "HH:MM"+/// in Sydney local time; `end` may be "24:00". `rate` is absent on a free+/// window — the domain ignores it there by contract.+///+/// Mirrors `plan.Window` on the backend.+public struct PlanWindow: Codable, Sendable, Equatable, Hashable {+    public var start: String+    public var end: String+    public var free: Bool+    public var rate: Double?++    public init(start: String, end: String, free: Bool, rate: Double?) {+        self.start = start+        self.end = end+        self.free = free+        self.rate = rate+    }+}++public extension PlanWindow {+    /// The exclusive upper bound of a band boundary. Boundaries are+    /// minute-of-day values in `0...1440`; 1440 ("24:00") is end-of-day and is+    /// only ever a window or segment end.+    static let minutesPerDay = 24 * 60++    /// Converts an "HH:MM" band boundary to minutes since midnight, accepting+    /// "24:00" as end-of-day. Returns `nil` for anything malformed or beyond+    /// the day (Q34 — the off-peak window parser rejects `h > 23` and cannot+    /// be reused for bands).+    static func parseBandTime(_ value: String) -> Int? {+        let chars = Array(value)+        guard chars.count == 5, chars[2] == ":" else { return nil }+        for index in [0, 1, 3, 4] where !chars[index].isASCII || !chars[index].isNumber {+            return nil+        }+        guard let hours = Int(String(chars[0 ..< 2])),+              let minutes = Int(String(chars[3 ..< 5])),+              minutes <= 59 else { return nil }+        let total = hours * 60 + minutes+        guard total <= minutesPerDay else { return nil }+        return total+    }++    /// The inverse of `parseBandTime`, rendering 1440 as "24:00".+    static func formatBandTime(_ minutes: Int) -> String {+        String(format: "%02d:%02d", minutes / 60, minutes % 60)+    }+}++/// A window whose boundaries have been resolved to minutes of the day. Shared+/// by segmentation and draft validation, which both need the same "is this+/// window well-formed" answer before they can do anything with it.+struct ParsedBand {+    let start: Int+    let end: Int+    let free: Bool+    let rate: Double++    /// Fails when the boundaries are unparseable or not in order — the cases+    /// `PricingPlanDraft.validate` reports as `bandWindowInvalid`.+    init?(_ window: PlanWindow) {+        guard let start = PlanWindow.parseBandTime(window.start),+              let end = PlanWindow.parseBandTime(window.end),+              start < end else { return nil }+        self.start = start+        self.end = end+        self.free = window.free+        self.rate = window.rate ?? 0+    }+}++/// One band of the derived full-day segmentation. The segments of a plan tile+/// 00:00–24:00 exactly (AC 1.1). Mirrors `plan.Segment`.+public struct PlanSegment: Sendable, Equatable, Hashable {+    public let start: String+    public let end: String+    public let free: Bool+    public let rate: Double++    public init(start: String, end: String, free: Bool, rate: Double) {+        self.start = start+        self.end = end+        self.free = free+        self.rate = rate+    }+}++/// Server-assigned pricing plan. The id and timestamps are populated by the+/// backend; the client treats them as opaque. Dates are kept as YYYY-MM-DD+/// strings to match `DayEnergy.date` and so day-membership tests can use+/// lexicographic string comparison.+///+/// The plan is stored as entered — a default rate plus the exception windows+/// that deviate from it (Decision 4) — and `endDate` is the exclusive switch+/// date (Decision 5). Mirrors `dynamo.PricingItem` / `plan.Plan`.+public struct PricingPlan: Identifiable, Codable, Sendable, Equatable, Hashable {+    public let id: String+    public let startDate: String+    /// Exclusive: the plan prices `[startDate, endDate)`, so a plan ending on+    /// the date its successor starts hands the switch day to the successor.+    /// `nil` means open-ended.+    public let endDate: String?+    public let defaultRate: Double+    public let windows: [PlanWindow]+    public let feedInRate: Double+    /// Present iff the plan has a free window; the rate free-window energy is+    /// valued at.+    public let savingsReferenceRate: Double?+    public let createdAt: Date+    public let updatedAt: Date++    public init(+        id: String,+        startDate: String,+        endDate: String?,+        defaultRate: Double,+        windows: [PlanWindow],+        feedInRate: Double,+        savingsReferenceRate: Double?,+        createdAt: Date,+        updatedAt: Date+    ) {+        self.id = id+        self.startDate = startDate+        self.endDate = endDate+        self.defaultRate = defaultRate+        self.windows = windows+        self.feedInRate = feedInRate+        self.savingsReferenceRate = savingsReferenceRate+        self.createdAt = createdAt+        self.updatedAt = updatedAt+    }++    /// Returns true iff `date` (YYYY-MM-DD) falls within `[startDate, endDate)`.+    /// The end date is exclusive (Decision 5), so the switch day belongs to the+    /// successor.+    public func covers(date: String) -> Bool {+        guard date >= startDate else { return false }+        guard let endDate else { return true }+        return date < endDate+    }+}++public extension PricingPlan {+    /// The plan's contiguous full-day band list, derived from the default rate+    /// and the exception windows. The result always starts at "00:00", ends at+    /// "24:00", and contains no gaps, overlaps, or zero-width entries.+    ///+    /// Abutting segments carrying the same rate are deliberately not merged+    /// (Q26): the stored per-band split joins to this geometry, so it has to+    /// stay stable against a merge that would depend on rate equality.+    ///+    /// Windows that fail to parse are skipped — `PricingPlanDraft.validate` is+    /// where they are reported, so this stays a total function.+    var segments: [PlanSegment] {+        let bands = windows+            .compactMap(ParsedBand.init)+            .sorted { $0.start < $1.start }++        var segments: [PlanSegment] = []+        segments.reserveCapacity(bands.count * 2 + 1)++        func appendDefault(fromMinute: Int, toMinute: Int) {+            guard fromMinute < toMinute else { return }+            segments.append(PlanSegment(+                start: PlanWindow.formatBandTime(fromMinute),+                end: PlanWindow.formatBandTime(toMinute),+                free: false,+                rate: defaultRate+            ))+        }++        var cursor = 0+        for band in bands {+            // An overlapping window is invalid (validate reports bandOverlap);+            // skipping it keeps the tiling invariant intact rather than+            // emitting an inverted segment.+            guard band.start >= cursor else { continue }+            appendDefault(fromMinute: cursor, toMinute: band.start)+            segments.append(PlanSegment(+                start: PlanWindow.formatBandTime(band.start),+                end: PlanWindow.formatBandTime(band.end),+                free: band.free,+                rate: band.free ? 0 : band.rate+            ))+            cursor = band.end+        }+        appendDefault(fromMinute: cursor, toMinute: PlanWindow.minutesPerDay)+        return segments+    }++    /// `segments` filtered to the non-free bands — the geometry stored in a+    /// day's `bandImports`. Free-window import lives on the off-peak row, which+    /// owns it exclusively (Q31).+    var ratedSegments: [PlanSegment] {+        segments.filter { !$0.free }+    }++    /// The plan's free band, or `nil` when it has none. Callers must treat+    /// `nil` as "no window" and never substitute a default window (AC 4.4).+    var freeWindow: PlanSegment? {+        segments.first { $0.free }+    }++    /// The plan pricing the given date. At most one plan covers any date+    /// (AC 2.1) — the validation rules make overlapping ranges unstorable — so+    /// the first match is the only match.+    static func plan(for date: String, in plans: [PricingPlan]) -> PricingPlan? {+        plans.first { $0.covers(date: date) }+    }++    /// The free window of the plan pricing the given date (AC 4.1). `nil` when+    /// no plan covers the date or the covering plan has no free band — the two+    /// "no window" outcomes callers treat alike.+    static func freeWindow(for date: String, in plans: [PricingPlan]) -> PlanSegment? {+        plan(for: date, in: plans)?.freeWindow+    }+}++/// Server response from POST /pricing/replace-open-ended: the closing plan+/// (its exclusive end date set to the successor's start date) and the new+/// open-ended plan.+public struct ReplaceOpenEndedResult: Sendable, Equatable {+    public let closing: PricingPlan+    public let newPlan: PricingPlan++    public init(closing: PricingPlan, newPlan: PricingPlan) {+        self.closing = closing+        self.newPlan = newPlan+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPlanDraft.swift Added +181 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPlanDraft.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPlanDraft.swiftnew file mode 100644index 0000000..2e16cc3--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPlanDraft.swift@@ -0,0 +1,181 @@+import Foundation++/// The editable shape used by the pricing editor. The backend assigns id /+/// createdAt / updatedAt on POST, so the draft only carries the writable+/// fields. Server-side validation is authoritative; local validation is for+/// early editor feedback and mirrors the server rules so a plan accepted+/// locally is not rejected for a rule the client could have checked (AC 6.4).+public struct PricingPlanDraft: Codable, Sendable, Equatable {+    public var startDate: String+    /// Exclusive switch date (Decision 5); `nil` means open-ended.+    public var endDate: String?+    public var defaultRate: Double+    public var windows: [PlanWindow]+    public var feedInRate: Double+    public var savingsReferenceRate: Double?++    public init(+        startDate: String = "",+        endDate: String? = nil,+        defaultRate: Double = 0,+        windows: [PlanWindow] = [],+        feedInRate: Double = 0,+        savingsReferenceRate: Double? = nil+    ) {+        self.startDate = startDate+        self.endDate = endDate+        self.defaultRate = defaultRate+        self.windows = windows+        self.feedInRate = feedInRate+        self.savingsReferenceRate = savingsReferenceRate+    }++    public init(plan: PricingPlan) {+        self.startDate = plan.startDate+        self.endDate = plan.endDate+        self.defaultRate = plan.defaultRate+        self.windows = plan.windows+        self.feedInRate = plan.feedInRate+        self.savingsReferenceRate = plan.savingsReferenceRate+    }++    /// Reasons a draft can fail validation. Mirrors the backend error codes in+    /// `internal/plan`; `invalidStartDate` / `invalidEndDate` are the local+    /// split of the server's single `inverted_dates` code. Cross-plan rules+    /// (date-range overlap, the single-open-ended rule) run server-side only.+    public enum ValidationError: Error, Equatable, Sendable {+        case invalidStartDate+        case invalidEndDate+        case invertedDates+        case bandWindowInvalid+        case bandOverlap+        case multipleFreeBands+        case noRatedBand+        case savingsRateMissing+        case rateOutOfRange+        case ratePrecision+    }++    /// The per-rate upper bound carried over from the flat-rate model — 10× the+    /// highest plausible AU retail tariff, which catches order-of-magnitude+    /// typos without constraining real use.+    public static let rateCap = 10.0++    /// Local pre-flight validation. The server re-validates — this is purely+    /// for early editor feedback. Returns `nil` when valid. The rule order+    /// matches the server's so the first message the editor shows is the one+    /// the server would report first.+    public func validate() -> ValidationError? {+        if let dateError = validateDates() { return dateError }+        if let bandError = validateBands() { return bandError }+        return validateRates()+    }++    /// Returns the rate rounded to exactly four decimal places. The backend+    /// stores rates at 4dp; this helper keeps the wire payload consistent.+    public static func roundedToFourDP(_ rate: Double) -> Double {+        (rate * 10_000).rounded() / 10_000+    }++    /// The draft with every rate normalised to the backend's 4dp storage+    /// precision. A free window's stale rate is dropped rather than rounded —+    /// it carries no rate by contract.+    public func normalised() -> PricingPlanDraft {+        PricingPlanDraft(+            startDate: startDate,+            endDate: endDate,+            defaultRate: Self.roundedToFourDP(defaultRate),+            windows: windows.map { window in+                PlanWindow(+                    start: window.start,+                    end: window.end,+                    free: window.free,+                    rate: window.free ? nil : Self.roundedToFourDP(window.rate ?? 0)+                )+            },+            feedInRate: Self.roundedToFourDP(feedInRate),+            savingsReferenceRate: savingsReferenceRate.map(Self.roundedToFourDP)+        )+    }++    // MARK: - Rule groups++    private func validateDates() -> ValidationError? {+        guard Self.isValidDate(startDate) else { return .invalidStartDate }+        guard let endDate else { return nil }+        guard Self.isValidDate(endDate) else { return .invalidEndDate }+        // Exclusive ends make endDate == startDate a plan that prices no days.+        guard endDate > startDate else { return .invertedDates }+        return nil+    }++    private func validateBands() -> ValidationError? {+        var parsed: [ParsedBand] = []+        for window in windows {+            guard let band = ParsedBand(window) else { return .bandWindowInvalid }+            parsed.append(band)+        }++        let sorted = parsed.sorted { $0.start < $1.start }+        for index in 1 ..< max(sorted.count, 1) where sorted[index].start < sorted[index - 1].end {+            return .bandOverlap+        }++        let free = parsed.filter(\.free)+        if free.count > 1 { return .multipleFreeBands }+        // AC 1.3: at least one rated band. The only way to have none is a free+        // window covering the whole day — a zero-width default remainder left+        // by rated windows tiling the rest is fine.+        let freeMinutes = free.reduce(0) { $0 + ($1.end - $1.start) }+        if !free.isEmpty, freeMinutes >= PlanWindow.minutesPerDay { return .noRatedBand }+        if !free.isEmpty, savingsReferenceRate == nil { return .savingsRateMissing }+        return nil+    }++    private func validateRates() -> ValidationError? {+        var rates = [defaultRate, feedInRate]+        if let savingsReferenceRate { rates.append(savingsReferenceRate) }+        // A free window's rate is skipped — it carries no rate by contract.+        rates.append(contentsOf: windows.filter { !$0.free }.map { $0.rate ?? 0 })++        for rate in rates {+            if !Self.fitsFourDecimalPlaces(rate) { return .ratePrecision }+            if rate < 0 || rate > Self.rateCap { return .rateOutOfRange }+        }+        return nil+    }++    // MARK: - Primitives++    private static func isValidDate(_ value: String) -> Bool {+        // YYYY-MM-DD — strictly 10 characters with hyphen positions at 5 and 8.+        guard value.count == 10 else { return false }+        let chars = Array(value)+        guard chars[4] == "-", chars[7] == "-" else { return false }+        guard let year = Int(String(chars[0 ..< 4])),+              let month = Int(String(chars[5 ..< 7])),+              let day = Int(String(chars[8 ..< 10])) else { return false }+        guard year >= 1970, year <= 9999 else { return false }+        guard (1 ... 12).contains(month) else { return false }+        guard (1 ... 31).contains(day) else { return false }+        // Calendar-day check so 2026-02-30 fails client-side instead of+        // round-tripping to the server and surfacing as the misleading+        // "endDate must not precede startDate" message. Go's time.Parse on the+        // wire already enforces this; this keeps the pre-flight validator in+        // agreement with the authoritative server check.+        var calendar = Calendar(identifier: .iso8601)+        calendar.timeZone = DateFormatting.sydneyTimeZone+        let components = DateComponents(year: year, month: month, day: day)+        return calendar.date(from: components).map {+            let resolved = calendar.dateComponents([.year, .month, .day], from: $0)+            return resolved.year == year && resolved.month == month && resolved.day == day+        } ?? false+    }++    private static func fitsFourDecimalPlaces(_ rate: Double) -> Bool {+        // A rate fits 4dp if rate * 10_000 is (numerically) very close to an+        // integer. Float64 noise at 4dp is well below 1e-6, so this is safe.+        let scaled = rate * 10_000+        return abs(scaled - scaled.rounded()) < 1e-6+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swift Modified +21 / -21
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swiftindex f2d0728..a388cb8 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swift@@ -1,7 +1,7 @@ import Foundation  /// Owns the pricing cache and the mutating CRUD path. View models read-/// `periods` directly; AC 2.7 requires a fetch on every UI entry point and+/// `plans` directly; AC 2.7 requires a fetch on every UI entry point and /// immediately after any mutation. The post-mutation refetch is /// fire-and-forget so the editor sees instant local feedback while the /// authoritative list lands on the next tick.@@ -10,7 +10,7 @@ import Foundation public final class PricingService {     public static let shared = PricingService() -    public private(set) var periods: [PricingPeriod] = []+    public private(set) var plans: [PricingPlan] = []     public private(set) var lastError: Error?      private var apiClient: (any FluxAPIClient)?@@ -36,7 +36,7 @@ public final class PricingService {             // Bail if the task was cancelled mid-flight — a fresher             // refresh has already overtaken us.             try Task.checkCancellation()-            periods = remote.sorted { $0.startDate < $1.startDate }+            plans = remote.sorted { $0.startDate < $1.startDate }             lastError = nil         } catch is CancellationError {             // Cancelled refreshes are not failures; leave state alone.@@ -48,7 +48,7 @@ public final class PricingService {     }      @discardableResult-    public func create(_ draft: PricingPeriodDraft) async throws -> PricingPeriod {+    public func create(_ draft: PricingPlanDraft) async throws -> PricingPlan {         guard let apiClient else {             lastError = FluxAPIError.notConfigured             throw FluxAPIError.notConfigured@@ -66,7 +66,7 @@ public final class PricingService {     }      @discardableResult-    public func update(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod {+    public func update(id: String, _ draft: PricingPlanDraft) async throws -> PricingPlan {         guard let apiClient else {             lastError = FluxAPIError.notConfigured             throw FluxAPIError.notConfigured@@ -90,7 +90,7 @@ public final class PricingService {         }         do {             try await apiClient.deletePricing(id: id)-            periods.removeAll { $0.id == id }+            plans.removeAll { $0.id == id }             lastError = nil             scheduleRefetch()         } catch {@@ -102,8 +102,8 @@ public final class PricingService {     @discardableResult     public func replaceOpenEnded(         closingId: String,-        with draft: PricingPeriodDraft-    ) async throws -> PricingPeriod {+        with draft: PricingPlanDraft+    ) async throws -> PricingPlan {         guard let apiClient else {             lastError = FluxAPIError.notConfigured             throw FluxAPIError.notConfigured@@ -112,34 +112,34 @@ public final class PricingService {             let result = try await apiClient.replaceOpenEndedPricing(closingId: closingId, with: draft)             // Sort once after both folds rather than re-sorting per insert.             foldInsert(result.closing, sort: false)-            foldInsert(result.newPeriod, sort: false)-            periods.sort { $0.startDate < $1.startDate }+            foldInsert(result.newPlan, sort: false)+            plans.sort { $0.startDate < $1.startDate }             lastError = nil             scheduleRefetch()-            return result.newPeriod+            return result.newPlan         } catch {             lastError = error             throw error         }     } -    private func foldInsert(_ period: PricingPeriod, sort: Bool = true) {-        if let idx = periods.firstIndex(where: { $0.id == period.id }) {-            periods[idx] = period+    private func foldInsert(_ plan: PricingPlan, sort: Bool = true) {+        if let idx = plans.firstIndex(where: { $0.id == plan.id }) {+            plans[idx] = plan         } else {-            periods.append(period)+            plans.append(plan)         }         if sort {-            periods.sort { $0.startDate < $1.startDate }+            plans.sort { $0.startDate < $1.startDate }         }     } -    private func foldReplace(_ period: PricingPeriod) {-        if let idx = periods.firstIndex(where: { $0.id == period.id }) {-            periods[idx] = period-            periods.sort { $0.startDate < $1.startDate }+    private func foldReplace(_ plan: PricingPlan) {+        if let idx = plans.firstIndex(where: { $0.id == plan.id }) {+            plans[idx] = plan+            plans.sort { $0.startDate < $1.startDate }         } else {-            foldInsert(period)+            foldInsert(plan)         }     } 
Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsBandTests.swift Added +191 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsBandTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsBandTests.swiftnew file mode 100644index 0000000..e474f7c--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/APIModelsBandTests.swift@@ -0,0 +1,191 @@+import Foundation+import Testing+@testable import FluxCore++/// Wire-shape coverage for the band split and the nullable off-peak window+/// (`time-of-use-pricing`).+@Suite+struct APIModelsBandTests {+    // MARK: - bandImports and off-peak provenance++    @Test+    func daySummaryDecodesBandImportsAndOffpeakGeometry() throws {+        let json = """+        {+          "epv": 12.0, "eInput": 23.0, "eOutput": 15.0,+          "eCharge": 8.0, "eDischarge": 7.0,+          "socLow": 20.0, "socLowTime": "2026-08-15T06:00:00+10:00",+          "offpeakGridImportKwh": 3.0,+          "offpeakWindowStart": "10:00",+          "offpeakWindowEnd": "15:00",+          "offpeakIntegratedAt": "2026-08-16T05:00:00Z",+          "offpeakSampleCount": 1500,+          "bandImports": [+            { "start": "00:00", "end": "01:00", "kwh": 1.0 },+            { "start": "01:00", "end": "06:00", "kwh": 4.0 },+            { "start": "06:00", "end": "10:00", "kwh": 2.0 },+            { "start": "15:00", "end": "24:00", "kwh": 8.0 }+          ]+        }+        """+        let summary = try JSONDecoder().decode(DaySummary.self, from: Data(json.utf8))+        #expect(summary.bandImports?.count == 4)+        #expect(summary.bandImports?[1] == BandImport(start: "01:00", end: "06:00", kwh: 4.0))+        #expect(summary.offpeakWindowStart == "10:00")+        #expect(summary.offpeakWindowEnd == "15:00")+        #expect(summary.offpeakIntegratedAt == "2026-08-16T05:00:00Z")+        #expect(summary.offpeakSampleCount == 1500)+    }++    @Test+    func daySummaryWithoutABandSplitDecodesToNil() throws {+        let json = """+        {+          "epv": 12.0, "eInput": 23.0, "eOutput": 15.0,+          "eCharge": 8.0, "eDischarge": 7.0,+          "socLow": null, "socLowTime": null+        }+        """+        let summary = try JSONDecoder().decode(DaySummary.self, from: Data(json.utf8))+        // Absent, not an empty array — an empty array would read as "zero+        // import in every band".+        #expect(summary.bandImports == nil)+        #expect(summary.offpeakWindowStart == nil)+        #expect(summary.offpeakSampleCount == nil)+    }++    @Test+    func dayEnergyDecodesBandImportsAndOffpeakGeometry() throws {+        let json = """+        {+          "date": "2026-08-15",+          "epv": 12.0, "eInput": 23.0, "eOutput": 15.0,+          "eCharge": 8.0, "eDischarge": 7.0,+          "offpeakGridImportKwh": 3.0,+          "offpeakWindowStart": "10:00",+          "offpeakWindowEnd": "15:00",+          "bandImports": [+            { "start": "00:00", "end": "10:00", "kwh": 6.0 },+            { "start": "15:00", "end": "24:00", "kwh": 8.0 }+          ],+          "note": null+        }+        """+        let day = try JSONDecoder().decode(DayEnergy.self, from: Data(json.utf8))+        #expect(day.bandImports?.count == 2)+        #expect(day.offpeakWindowStart == "10:00")+        #expect(day.offpeakWindowEnd == "15:00")+        #expect(day.offpeakIntegratedAt == nil)+    }++    // MARK: - Off-peak row reconstruction++    @Test+    func costInputsBuildTheOffpeakRowFromTheFlatWireFields() {+        let summary = DaySummary(+            epv: nil, eInput: 23, eOutput: 15,+            eCharge: nil, eDischarge: nil,+            socLow: nil, socLowTime: nil,+            offpeakGridImportKwh: 3,+            offpeakWindowStart: "10:00",+            offpeakWindowEnd: "15:00",+            offpeakIntegratedAt: "2026-08-16T05:00:00Z",+            offpeakSampleCount: 1500+        )+        let offpeak = summary.costInputs.offpeak+        #expect(offpeak?.gridImportKwh == 3)+        #expect(offpeak?.geometry.start == "10:00")+        #expect(offpeak?.isUsable == true)+    }++    @Test+    func aDayWithNoOffpeakImportHasNoOffpeakRowAtAll() {+        let summary = DaySummary(+            epv: nil, eInput: 23, eOutput: 15,+            eCharge: nil, eDischarge: nil,+            socLow: nil, socLowTime: nil,+            offpeakGridImportKwh: nil+        )+        #expect(summary.costInputs.offpeak == nil)+    }++    @Test+    func aRowWithoutGeometryFallsBackToTheOnlyWindowItCanHaveHad() {+        let row = OffpeakImport(gridImportKwh: 6)+        #expect(row.geometry.start == "11:00")+        #expect(row.geometry.end == "14:00")+        #expect(row.isUsable)+    }++    @Test+    func aSparseCompleteRowIsNotAMeasurement() {+        let row = OffpeakImport(+            gridImportKwh: 0,+            windowStart: "10:00",+            windowEnd: "15:00",+            integratedAt: "2026-08-16T05:00:00Z",+            sampleCount: 0+        )+        #expect(!row.isUsable)+    }++    // MARK: - Nullable off-peak window (Q35)++    @Test+    func offpeakDataDecodesWithoutWindowStrings() throws {+        let json = """+        {+          "windowStart": null,+          "windowEnd": null,+          "gridUsageKwh": 1.0,+          "solarKwh": null,+          "batteryChargeKwh": null,+          "batteryDischargeKwh": null,+          "gridExportKwh": null,+          "batteryDeltaPercent": null,+          "projectedEndSoc": null+        }+        """+        let offpeak = try JSONDecoder().decode(OffpeakData.self, from: Data(json.utf8))+        #expect(offpeak.windowStart == nil)+        #expect(offpeak.windowEnd == nil)+        #expect(offpeak.gridUsageKwh == 1.0)+    }++    @Test+    func aNoFreeBandDayServesANullOffpeakObject() throws {+        let json = """+        {+          "live": null, "battery": null, "rolling15min": null,+          "offpeak": null, "todayEnergy": null, "note": null+        }+        """+        let status = try JSONDecoder().decode(StatusResponse.self, from: Data(json.utf8))+        #expect(status.offpeak == nil)+    }++    // MARK: - No default-window substitution++    @Test+    func gridTintTreatsAnAbsentWindowAsOutsideTheWindow() {+        let now = Date(timeIntervalSince1970: 1_776_000_000)+        // Sustained import above the threshold is red when there is no free+        // window to protect it — the same outcome as being outside one.+        let tier = GridColor.forGrid(+            pgrid: 900,+            pgridSustained: true,+            offpeakWindowStart: nil,+            offpeakWindowEnd: nil,+            now: now+        )+        #expect(tier == .red)+    }++    @Test+    func cutoffTintIsNeutralWithoutAWindow() {+        let now = Date(timeIntervalSince1970: 1_776_000_000)+        // Far enough out that only the window comparison could escalate it.+        let cutoff = now.addingTimeInterval(5 * 60 * 60)+        #expect(CutoffTimeColor.forCutoff(cutoff, offpeakWindowStart: nil, now: now) == .normal)+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/DateFormattingTests.swift Modified +30 / -1
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DateFormattingTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DateFormattingTests.swiftindex f11df5a..a7a0240 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DateFormattingTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DateFormattingTests.swift@@ -62,8 +62,37 @@ import Testing     @Test     func parseWindowTimeRejectsInvalidFormats() {         #expect(DateFormatting.parseWindowTime("invalid") == nil)-        #expect(DateFormatting.parseWindowTime("24:00") == nil)         #expect(DateFormatting.parseWindowTime("09:60") == nil)+        // 24:00 is the only valid hour-24 value: it is the end-of-day sentinel,+        // not a time of day.+        #expect(DateFormatting.parseWindowTime("24:01") == nil)+        #expect(DateFormatting.parseWindowTime("25:00") == nil)+    }++    @Test+    func parseWindowTimeResolvesEndOfDaySentinel() throws {+        // A plan's free band may run to midnight, and the band model expresses+        // that as an end of "24:00" (plan.ParseBandTime / PlanWindow accept it).+        // Rejecting it here would make every consumer treat such a day as+        // having no free window at all.+        let now = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 9, minute: 5)+        let midnight = try #require(DateFormatting.parseWindowTime("24:00", on: now))+        let components = sydneyCalendar.dateComponents([.year, .month, .day, .hour, .minute], from: midnight)++        #expect(components.year == 2026)+        #expect(components.month == 4)+        #expect(components.day == 16, "end-of-day is the start of the following day")+        #expect(components.hour == 0)+        #expect(components.minute == 0)+    }++    @Test+    func isInOffpeakWindowHandlesAFreeBandRunningToMidnight() {+        let insideWindow = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 23, minute: 30)+        let beforeWindow = makeSydneyDate(year: 2026, month: 4, day: 15, hour: 19, minute: 59)++        #expect(DateFormatting.isInOffpeakWindow(start: "20:00", end: "24:00", now: insideWindow))+        #expect(DateFormatting.isInOffpeakWindow(start: "20:00", end: "24:00", now: beforeWindow) == false)     }      @Test
Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsTests.swift Modified +253 / -106
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsTests.swiftindex d15d427..f5646f6 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsTests.swift@@ -4,199 +4,336 @@ import Testing  @Suite struct DayCostsTests {-    // MARK: - DaySummary.costs(forDate:in:)+    // MARK: - Tier 2: the pre-band formula, verbatim (Q30)      @Test-    func costsHappyPathWithFullSplit() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+    func singleRatePlanUsesTheLegacyFormulaWithTheOffpeakResidual() throws {+        let pricing = [singleRatePlan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]         let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3) -        let costs = summary.costs(forDate: "2026-04-15", in: pricing)-        let unwrapped = try #require(costs)+        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        #expect(costs.tier == .singleRate)         // peak imports kWh = 10 - 3 = 7-        #expect(unwrapped.peakImportsCost == 7 * 0.30)-        #expect(unwrapped.solarFeedInIncome == 4 * 0.05)-        #expect(unwrapped.offPeakSavings == 3 * 0.10)-        #expect(unwrapped.net == 7 * 0.30 - 4 * 0.05)+        #expect(costs.peakImportsCost == 7 * 0.30)+        #expect(costs.solarFeedInIncome == 4 * 0.05)+        #expect(costs.offPeakSavings == 3 * 0.10)+        #expect(costs.net == 7 * 0.30 - 4 * 0.05)     }      @Test-    func costsTreatsNilOffpeakSplitAsZeroAndBillsAllImportsAsPeak() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+    func nilOffpeakSplitBillsAllImportsAtThePlanRateWithNoSavings() throws {+        let pricing = [singleRatePlan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]         let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: nil) -        let costs = summary.costs(forDate: "2026-04-15", in: pricing)-        let unwrapped = try #require(costs)-        // Decision 23: all 10 kWh peak when split is nil.-        #expect(unwrapped.peakImportsCost == 10 * 0.30)-        #expect(unwrapped.offPeakSavings == 0)+        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        #expect(costs.peakImportsCost == 10 * 0.30)+        #expect(costs.offPeakSavings == 0)     }      @Test-    func costsTreatsNilFieldsAsZero() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+    func nilEnergyFieldsAreTreatedAsZeroAndStillPriceTheDay() throws {+        let pricing = [singleRatePlan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]         let summary = DaySummary(             epv: nil, eInput: nil, eOutput: nil,             eCharge: nil, eDischarge: nil,-            socLow: nil, socLowTime: nil,-            offpeakGridImportKwh: nil, offpeakGridExportKwh: nil+            socLow: nil, socLowTime: nil         )-        let costs = summary.costs(forDate: "2026-04-15", in: pricing)-        let unwrapped = try #require(costs)-        #expect(unwrapped.peakImportsCost == 0)-        #expect(unwrapped.solarFeedInIncome == 0)-        #expect(unwrapped.offPeakSavings == 0)-        #expect(unwrapped.net == 0)+        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        #expect(costs.peakImportsCost == 0)+        #expect(costs.solarFeedInIncome == 0)+        #expect(costs.offPeakSavings == 0)+        #expect(costs.net == 0)     }      @Test-    func costsZeroValuesProduceZeroLines() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]-        let summary = makeSummary(eInput: 0, eOutput: 0, offpeakKwh: 0)+    func serverPeakIsPreferredOverTheResidual() throws {+        // Server peak (6.5) deliberately differs from the residual+        // eInput − offpeak (10 − 3 = 7), so this proves the measured value wins.+        let pricing = [singleRatePlan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]+        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3, peakKwh: 6.5) -        let costs = summary.costs(forDate: "2026-04-15", in: pricing)-        let unwrapped = try #require(costs)-        #expect(unwrapped.peakImportsCost == 0)-        #expect(unwrapped.solarFeedInIncome == 0)-        #expect(unwrapped.offPeakSavings == 0)-        #expect(unwrapped.net == 0)+        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        #expect(costs.peakImportsCost == 6.5 * 0.30)+        // Savings still price the measured off-peak kWh unchanged.+        #expect(costs.offPeakSavings == 3 * 0.10)+        #expect(costs.net == 6.5 * 0.30 - 4 * 0.05)     }      @Test-    func costsReturnsNilWhenDateNotCoveredByAnyPeriod() throws {-        let pricing = [period(start: "2026-04-01", end: "2026-04-30", peak: 0.30, feedIn: 0.05, offPeak: 0.10)]-        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3)+    func peakKwhIsClampedAtZeroWhenOffpeakExceedsEInput() throws {+        let pricing = [singleRatePlan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]+        let summary = makeSummary(eInput: 2, eOutput: 0, offpeakKwh: 5)+        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        #expect(costs.peakImportsCost == 0)+    } -        #expect(summary.costs(forDate: "2026-05-01", in: pricing) == nil)-        #expect(summary.costs(forDate: "2026-03-31", in: pricing) == nil)+    @Test+    func aSingleRatePlanNeverReachesTheFallbackTier() throws {+        // No split, no server peak, no off-peak row — tier 2 still resolves.+        let pricing = [singleRatePlan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]+        let summary = makeSummary(eInput: 10, eOutput: 0, offpeakKwh: nil)+        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        #expect(costs.tier == .singleRate)     } +    // MARK: - Tier 1: the stored band split+     @Test-    func costsPicksTheCoveringPeriodWhenMultiplePresent() throws {-        let pricing = [-            period(id: "old", start: "2026-01-01", end: "2026-03-31", peak: 0.20, feedIn: 0.04, offPeak: 0.08),-            period(id: "new", start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.06, offPeak: 0.12)-        ]-        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3)+    func bandedPathPricesEachRatedBandAtItsOwnRate() throws {+        let pricing = [touPlan(start: "2026-08-01", end: nil)]+        let summary = makeSummary(+            eInput: 23, eOutput: 15, offpeakKwh: 3,+            offpeakWindowStart: "10:00", offpeakWindowEnd: "15:00",+            offpeakIntegratedAt: "2026-08-02T05:00:00Z", offpeakSampleCount: 1500,+            bandImports: [+                BandImport(start: "00:00", end: "01:00", kwh: 1),+                BandImport(start: "01:00", end: "06:00", kwh: 4),+                BandImport(start: "06:00", end: "10:00", kwh: 2),+                BandImport(start: "15:00", end: "24:00", kwh: 8)+            ]+        )+        let costs = try #require(summary.costs(forDate: "2026-08-15", in: pricing))+        #expect(costs.tier == .banded)+        #expect(abs(costs.peakImportsCost - (1 * 0.35 + 4 * 0.28 + 2 * 0.35 + 8 * 0.35)) < 1e-9)+        #expect(abs(costs.offPeakSavings - 3 * 0.35) < 1e-9)+    } -        let aprilCosts = summary.costs(forDate: "2026-04-15", in: pricing)-        #expect(aprilCosts?.peakImportsCost == 7 * 0.30)+    @Test+    func aSparseCompleteOffpeakRowCannotPriceTheFreeBand() throws {+        // integratedAt set with no samples is a zero-delta artifact, not a+        // measured zero, so the free import is unresolvable.+        let pricing = [touPlan(start: "2026-08-01", end: nil)]+        let summary = makeSummary(+            eInput: 23, eOutput: 15, offpeakKwh: 0,+            offpeakWindowStart: "10:00", offpeakWindowEnd: "15:00",+            offpeakIntegratedAt: "2026-08-02T05:00:00Z", offpeakSampleCount: 0,+            bandImports: [+                BandImport(start: "00:00", end: "01:00", kwh: 1),+                BandImport(start: "01:00", end: "06:00", kwh: 4),+                BandImport(start: "06:00", end: "10:00", kwh: 2),+                BandImport(start: "15:00", end: "24:00", kwh: 8)+            ]+        )+        let costs = try #require(summary.costs(forDate: "2026-08-15", in: pricing))+        #expect(costs.tier == .fallback)+    } -        let marchCosts = summary.costs(forDate: "2026-03-15", in: pricing)-        #expect(marchCosts?.peakImportsCost == 7 * 0.20)+    @Test+    func aPartiallyKnownSplitIsUnavailableNotPartiallyUsed() throws {+        let pricing = [touPlan(start: "2026-08-01", end: nil)]+        let summary = makeSummary(+            eInput: 23, eOutput: 15, offpeakKwh: 3,+            offpeakWindowStart: "10:00", offpeakWindowEnd: "15:00",+            offpeakIntegratedAt: "2026-08-02T05:00:00Z", offpeakSampleCount: 1500,+            bandImports: [+                BandImport(start: "00:00", end: "01:00", kwh: 1),+                BandImport(start: "01:00", end: "06:00", kwh: 4)+            ]+        )+        let costs = try #require(summary.costs(forDate: "2026-08-15", in: pricing))+        #expect(costs.tier == .fallback)+        #expect(abs(costs.peakImportsCost - 23 * 0.35) < 1e-9)+        #expect(costs.offPeakSavings == 0)     }      @Test-    func netExcludesOffPeakSavings() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 1.00)]-        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3)+    func anOffpeakRowWithoutGeometryIsTreatedAsTheLegacyWindow() throws {+        // Pre-feature rows carry no snapshot; 11:00–14:00 is the only window+        // they can have been computed under, so they match a migrated plan.+        let pricing = [migratedPlan(start: "2026-04-01", end: nil)]+        let summary = makeSummary(+            eInput: 20, eOutput: 15, offpeakKwh: 6,+            bandImports: [+                BandImport(start: "00:00", end: "11:00", kwh: 5),+                BandImport(start: "14:00", end: "24:00", kwh: 9)+            ]+        )         let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))-        #expect(costs.net == 7 * 0.30 - 4 * 0.05)-        #expect(costs.offPeakSavings == 3 * 1.00)+        #expect(costs.tier == .banded)+        #expect(abs(costs.peakImportsCost - 14 * 0.35) < 1e-9)     }      @Test-    func costsPrefersServerPeakOverResidualWhenPresent() throws {-        // Server peak (6.5) deliberately differs from the residual eInput-offpeak-        // (10-3=7) so the assertion proves the server value is used, not the residual.-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]-        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3, peakKwh: 6.5)-        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))-        #expect(costs.peakImportsCost == 6.5 * 0.30)-        // Off-peak savings still price the measured off-peak kWh unchanged.-        #expect(costs.offPeakSavings == 3 * 0.10)-        #expect(costs.net == 6.5 * 0.30 - 4 * 0.05)+    func aPlanWithoutAFreeBandNeedsNoOffpeakRow() throws {+        let plan = PricingPlan(+            id: "p", startDate: "2026-08-01", endDate: nil,+            defaultRate: 0.35,+            windows: [PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)],+            feedInRate: 0.05, savingsReferenceRate: nil,+            createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0)+        )+        let summary = makeSummary(+            eInput: 15, eOutput: 15, offpeakKwh: nil,+            bandImports: [+                BandImport(start: "00:00", end: "01:00", kwh: 1),+                BandImport(start: "01:00", end: "06:00", kwh: 4),+                BandImport(start: "06:00", end: "24:00", kwh: 10)+            ]+        )+        let costs = try #require(summary.costs(forDate: "2026-08-15", in: [plan]))+        #expect(costs.tier == .banded)+        #expect(costs.offPeakSavings == 0)     } +    // MARK: - Tier 3: the fallback (AC 3.5 / 3.6)+     @Test-    func costsFallsBackToResidualWhenServerPeakNil() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]-        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3, peakKwh: nil)-        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))-        #expect(costs.peakImportsCost == 7 * 0.30)+    func aMultiRatePlanWithNoSplitPricesEverythingAtTheHighestRate() throws {+        let pricing = [touPlan(start: "2026-08-01", end: nil)]+        let summary = makeSummary(eInput: 23, eOutput: 15, offpeakKwh: 3)+        let costs = try #require(summary.costs(forDate: "2026-08-15", in: pricing))+        #expect(costs.tier == .fallback)+        #expect(abs(costs.peakImportsCost - 23 * 0.35) < 1e-9)+        #expect(costs.offPeakSavings == 0)     }      @Test-    func peakImportsKwhClampedAtZeroWhenOffpeakExceedsEInput() throws {-        // Off-peak should never exceed eInput in real data, but the-        // computation must not produce a negative peak-imports value.-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]-        let summary = makeSummary(eInput: 2, eOutput: 0, offpeakKwh: 5)-        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))-        #expect(costs.peakImportsCost == 0)+    func aSplitCapturedUnderTheOldWindowDegradesToTheFallback() throws {+        let pricing = [touPlan(start: "2026-08-01", end: nil)]+        let summary = makeSummary(+            eInput: 23, eOutput: 15, offpeakKwh: 3,+            offpeakWindowStart: "10:00", offpeakWindowEnd: "15:00",+            offpeakIntegratedAt: "2026-08-02T05:00:00Z", offpeakSampleCount: 1500,+            bandImports: [+                BandImport(start: "00:00", end: "01:00", kwh: 1),+                BandImport(start: "01:00", end: "06:00", kwh: 4),+                BandImport(start: "06:00", end: "11:00", kwh: 2.5),+                BandImport(start: "14:00", end: "24:00", kwh: 9)+            ]+        )+        let costs = try #require(summary.costs(forDate: "2026-08-15", in: pricing))+        #expect(costs.tier == .fallback)+    }++    // MARK: - Plan coverage++    @Test+    func returnsNilWhenNoPlanCoversTheDate() throws {+        let pricing = [singleRatePlan(start: "2026-04-01", end: "2026-05-01", rate: 0.30, feedIn: 0.05, savings: 0.10)]+        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3)++        #expect(summary.costs(forDate: "2026-05-01", in: pricing) == nil)+        #expect(summary.costs(forDate: "2026-03-31", in: pricing) == nil)+        #expect(summary.costs(forDate: "2026-04-30", in: pricing) != nil)+    }++    @Test+    func switchDayIsPricedByTheSuccessor() throws {+        let pricing = [+            singleRatePlan(id: "old", start: "2026-01-01", end: "2026-08-01", rate: 0.20, feedIn: 0.04, savings: 0.08),+            singleRatePlan(id: "new", start: "2026-08-01", end: nil, rate: 0.30, feedIn: 0.06, savings: 0.12)+        ]+        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3)++        #expect(summary.costs(forDate: "2026-07-31", in: pricing)?.peakImportsCost == 7 * 0.20)+        #expect(summary.costs(forDate: "2026-08-01", in: pricing)?.peakImportsCost == 7 * 0.30)+    }++    @Test+    func emptyPlanListReturnsNil() throws {+        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3)+        #expect(summary.costs(forDate: "2026-04-15", in: []) == nil)     }      // MARK: - DayEnergy.costs(in:)      @Test-    func dayEnergyForwardsToDaySummaryExtension() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+    func dayEnergyForwardsEveryCostInputIntoTheHelper() throws {+        let pricing = [singleRatePlan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]         let day = DayEnergy(             date: "2026-04-15",             epv: 0, eInput: 10, eOutput: 4,             eCharge: 0, eDischarge: 0,             offpeakGridImportKwh: 3, offpeakGridExportKwh: nil,+            peakGridImportKwh: 6.5,             note: nil         )         let costs = try #require(day.costs(in: pricing))-        #expect(costs.peakImportsCost == 7 * 0.30)+        #expect(costs.peakImportsCost == 6.5 * 0.30)         #expect(costs.solarFeedInIncome == 4 * 0.05)         #expect(costs.offPeakSavings == 3 * 0.10)     }      @Test-    func dayEnergyForwardsServerPeakIntoCosts() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+    func dayEnergyForwardsTheBandSplit() throws {+        let pricing = [touPlan(start: "2026-08-01", end: nil)]         let day = DayEnergy(-            date: "2026-04-15",-            epv: 0, eInput: 10, eOutput: 4,+            date: "2026-08-15",+            epv: 0, eInput: 23, eOutput: 15,             eCharge: 0, eDischarge: 0,             offpeakGridImportKwh: 3, offpeakGridExportKwh: nil,-            peakGridImportKwh: 6.5,+            peakGridImportKwh: nil,+            bandImports: [+                BandImport(start: "00:00", end: "01:00", kwh: 1),+                BandImport(start: "01:00", end: "06:00", kwh: 4),+                BandImport(start: "06:00", end: "10:00", kwh: 2),+                BandImport(start: "15:00", end: "24:00", kwh: 8)+            ],+            offpeakWindowStart: "10:00",+            offpeakWindowEnd: "15:00",+            offpeakIntegratedAt: "2026-08-16T05:00:00Z",+            offpeakSampleCount: 1500,             note: nil         )         let costs = try #require(day.costs(in: pricing))-        // Forwarder must carry the server peak into the transient DaySummary.-        #expect(costs.peakImportsCost == 6.5 * 0.30)+        #expect(costs.tier == .banded)     }      @Test     func dayEnergyReturnsNilWhenDateNotCovered() throws {-        let pricing = [period(start: "2026-04-01", end: "2026-04-30", peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+        let pricing = [singleRatePlan(start: "2026-04-01", end: "2026-05-01", rate: 0.30, feedIn: 0.05, savings: 0.10)]         let day = DayEnergy(             date: "2026-05-15",             epv: 0, eInput: 10, eOutput: 4,             eCharge: 0, eDischarge: 0,-            offpeakGridImportKwh: 3,-            offpeakGridExportKwh: nil,+            offpeakGridImportKwh: 3, offpeakGridExportKwh: nil,             note: nil         )         #expect(day.costs(in: pricing) == nil)     } -    @Test-    func emptyPricingArrayReturnsNil() throws {-        let summary = makeSummary(eInput: 10, eOutput: 4, offpeakKwh: 3)-        #expect(summary.costs(forDate: "2026-04-15", in: []) == nil)-    }--    // MARK: - helpers+    // MARK: - Helpers -    private func period(+    private func singleRatePlan(         id: String = "p",         start: String,         end: String?,-        peak: Double,+        rate: Double,         feedIn: Double,-        offPeak: Double-    ) -> PricingPeriod {-        PricingPeriod(+        savings: Double+    ) -> PricingPlan {+        PricingPlan(             id: id,             startDate: start,             endDate: end,-            peakRate: peak,+            defaultRate: rate,+            windows: [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)],             feedInRate: feedIn,-            offPeakSavingsRate: offPeak,+            savingsReferenceRate: savings,+            createdAt: Date(timeIntervalSince1970: 1),+            updatedAt: Date(timeIntervalSince1970: 1)+        )+    }++    /// The shape every migrated legacy period takes: free 11:00–14:00 plus one+    /// flat rate for the rest of the day (AC 5.1).+    private func migratedPlan(start: String, end: String?) -> PricingPlan {+        singleRatePlan(start: start, end: end, rate: 0.35, feedIn: 0.05, savings: 0.35)+    }++    /// The incoming time-of-use plan: free 10:00–15:00, cheaper 01:00–06:00.+    private func touPlan(start: String, end: String?) -> PricingPlan {+        PricingPlan(+            id: "tou",+            startDate: start,+            endDate: end,+            defaultRate: 0.35,+            windows: [+                PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+                PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)+            ],+            feedInRate: 0.05,+            savingsReferenceRate: 0.35,             createdAt: Date(timeIntervalSince1970: 1),             updatedAt: Date(timeIntervalSince1970: 1)         )@@ -206,7 +343,12 @@ struct DayCostsTests {         eInput: Double?,         eOutput: Double?,         offpeakKwh: Double?,-        peakKwh: Double? = nil+        peakKwh: Double? = nil,+        offpeakWindowStart: String? = nil,+        offpeakWindowEnd: String? = nil,+        offpeakIntegratedAt: String? = nil,+        offpeakSampleCount: Int? = nil,+        bandImports: [BandImport]? = nil     ) -> DaySummary {         DaySummary(             epv: nil,@@ -218,7 +360,12 @@ struct DayCostsTests {             socLowTime: nil,             offpeakGridImportKwh: offpeakKwh,             offpeakGridExportKwh: nil,-            peakGridImportKwh: peakKwh+            peakGridImportKwh: peakKwh,+            bandImports: bandImports,+            offpeakWindowStart: offpeakWindowStart,+            offpeakWindowEnd: offpeakWindowEnd,+            offpeakIntegratedAt: offpeakIntegratedAt,+            offpeakSampleCount: offpeakSampleCount         )     } }
Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsVectorTests.swift Added +122 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsVectorTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsVectorTests.swiftnew file mode 100644index 0000000..0f854e0--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsVectorTests.swift@@ -0,0 +1,122 @@+import Foundation+import Testing+@testable import FluxCore++/// Three-tier cost resolution exists in both Go (`plan.DayCosts`) and Swift.+/// `internal/api/testdata/pricing_costs.json` pins the two to identical+/// numbers, and its tier-2 rows are also the migration tool's golden formula —+/// so these vectors are the AC 5.2 proof on the client side.+@Suite+struct DayCostsVectorTests {+    @Test+    func costResolutionMatchesTheSharedVectors() throws {+        let vectors = try Self.loadVectors()+        #expect(!vectors.isEmpty)++        for vector in vectors {+            let costs = DayCosts.resolve(plan: vector.plan.asPlan(), day: vector.day.asInputs())+            #expect(costs.tier.rawValue == vector.expected.tier, "\(vector.name) tier")+            #expect(Self.close(costs.peakImportsCost, vector.expected.importCost), "\(vector.name) importCost")+            #expect(Self.close(costs.solarFeedInIncome, vector.expected.feedInIncome), "\(vector.name) feedInIncome")+            #expect(Self.close(costs.net, vector.expected.net), "\(vector.name) net")+            #expect(Self.close(costs.offPeakSavings, vector.expected.savings), "\(vector.name) savings")+        }+    }++    @Test+    func netIsAlwaysImportMinusFeedInAcrossEveryTier() throws {+        for vector in try Self.loadVectors() {+            let costs = DayCosts.resolve(plan: vector.plan.asPlan(), day: vector.day.asInputs())+            #expect(+                Self.close(costs.net, costs.peakImportsCost - costs.solarFeedInIncome),+                "\(vector.name) net invariant"+            )+        }+    }++    @Test+    func everyTierIsExercisedByTheVectors() throws {+        let tiers = Set(try Self.loadVectors().map(\.expected.tier))+        #expect(tiers == [1, 2, 3])+    }++    // MARK: - Vector fixtures++    private struct Vector: Decodable {+        let name: String+        let plan: VectorPlan+        let day: VectorDay+        let expected: VectorExpected+    }++    private struct VectorPlan: Decodable {+        let defaultRate: Double+        let windows: [PlanWindow]+        let feedInRate: Double+        let savingsReferenceRate: Double?++        func asPlan() -> PricingPlan {+            PricingPlan(+                id: "vector",+                startDate: "2026-01-01",+                endDate: nil,+                defaultRate: defaultRate,+                windows: windows,+                feedInRate: feedInRate,+                savingsReferenceRate: savingsReferenceRate,+                createdAt: Date(timeIntervalSince1970: 0),+                updatedAt: Date(timeIntervalSince1970: 0)+            )+        }+    }++    private struct VectorOffpeak: Decodable {+        let gridImportKwh: Double+        let windowStart: String?+        let windowEnd: String?+        let integratedAt: String?+        let sampleCount: Int+    }++    private struct VectorDay: Decodable {+        let eInput: Double?+        let eOutput: Double?+        let peakGridImportKwh: Double?+        let offpeak: VectorOffpeak?+        let bandImports: [BandImport]?++        func asInputs() -> DayCostInputs {+            DayCostInputs(+                eInput: eInput,+                eOutput: eOutput,+                peakGridImportKwh: peakGridImportKwh,+                offpeak: offpeak.map {+                    OffpeakImport(+                        gridImportKwh: $0.gridImportKwh,+                        windowStart: $0.windowStart,+                        windowEnd: $0.windowEnd,+                        integratedAt: $0.integratedAt,+                        sampleCount: $0.sampleCount+                    )+                },+                bandImports: bandImports+            )+        }+    }++    private struct VectorExpected: Decodable {+        let tier: Int+        let importCost: Double+        let feedInIncome: Double+        let net: Double+        let savings: Double+    }++    private static func loadVectors(file: String = #filePath) throws -> [Vector] {+        try JSONDecoder().decode([Vector].self, from: Data(contentsOf: vectorURL(named: "pricing_costs.json", file: file)))+    }++    private static func close(_ lhs: Double, _ rhs: Double) -> Bool {+        abs(lhs - rhs) < 1e-9+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/PeriodCostsTests.swift Modified +79 / -66
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PeriodCostsTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PeriodCostsTests.swiftindex 8dc11bc..8881b89 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PeriodCostsTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PeriodCostsTests.swift@@ -8,14 +8,14 @@ struct PeriodCostsTests {      @Test     func emptyDaysReturnsNil() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+        let pricing = [plan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]         #expect(PeriodCosts.compute(days: [], pricing: pricing) == nil)     }      @Test     func zeroPricedDaysReturnsNil() throws {-        // Days exist but none is covered by any pricing period (AC 5.4).-        let pricing = [period(start: "2030-01-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+        // Days exist but none is covered by any plan (AC 2.7).+        let pricing = [plan(start: "2030-01-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]         let days = [             day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),             day(date: "2026-04-16", eInput: 12, eOutput: 5, offpeakKwh: 2)@@ -25,7 +25,7 @@ struct PeriodCostsTests {      @Test     func fullCoverageNoCaption() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+        let pricing = [plan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]         let days = [             day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),             day(date: "2026-04-16", eInput: 12, eOutput: 5, offpeakKwh: 2)@@ -38,7 +38,7 @@ struct PeriodCostsTests {      @Test     func partialCoverageReportsCount() throws {-        let pricing = [period(start: "2026-04-01", end: "2026-04-30", peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+        let pricing = [plan(start: "2026-04-01", end: "2026-05-01", rate: 0.30, feedIn: 0.05, savings: 0.10)]         let days = [             day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),             day(date: "2026-05-01", eInput: 12, eOutput: 5, offpeakKwh: 2),@@ -52,7 +52,7 @@ struct PeriodCostsTests {      @Test     func totalsExcludeUnpricedDays() throws {-        let pricing = [period(start: "2026-04-01", end: "2026-04-30", peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+        let pricing = [plan(start: "2026-04-01", end: "2026-05-01", rate: 0.30, feedIn: 0.05, savings: 0.10)]         let days = [             day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),             day(date: "2026-05-01", eInput: 999, eOutput: 999, offpeakKwh: 999)@@ -65,11 +65,26 @@ struct PeriodCostsTests {         #expect(totals.net == 7 * 0.30 - 4 * 0.05)     } +    @Test+    func daysSpanningASwitchDateArePricedByTheirOwnPlan() throws {+        let pricing = [+            plan(id: "old", start: "2026-01-01", end: "2026-08-01", rate: 0.20, feedIn: 0.05, savings: 0.10),+            plan(id: "new", start: "2026-08-01", end: nil, rate: 0.40, feedIn: 0.05, savings: 0.10)+        ]+        let days = [+            day(date: "2026-07-31", eInput: 10, eOutput: 0, offpeakKwh: 0),+            day(date: "2026-08-01", eInput: 10, eOutput: 0, offpeakKwh: 0)+        ]+        let totals = try #require(PeriodCosts.compute(days: days, pricing: pricing))+        #expect(totals.pricedDayCount == 2)+        #expect(approximately(totals.peakImportsCost, 10 * 0.20 + 10 * 0.40))+    }+     // MARK: - Net invariant      @Test     func netEqualsSumOfPerDayNets() throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+        let pricing = [plan(start: "2026-04-01", end: nil, rate: 0.30, feedIn: 0.05, savings: 0.10)]         let days = [             day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),             day(date: "2026-04-16", eInput: 12, eOutput: 5, offpeakKwh: 2),@@ -78,8 +93,7 @@ struct PeriodCostsTests {         let totals = try #require(PeriodCosts.compute(days: days, pricing: pricing))          let perDayNets = days.compactMap { $0.costs(in: pricing)?.net }-        let summed = perDayNets.reduce(0, +)-        #expect(approximately(totals.net, summed))+        #expect(approximately(totals.net, perDayNets.reduce(0, +)))     }      // MARK: - Linearity (property-based)@@ -93,13 +107,8 @@ struct PeriodCostsTests {         (1.0, 1.0)     ])     func costLinearityPerLine(rate: Double, kwh: Double) throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: rate, feedIn: rate, offPeak: rate)]-        let summary = DaySummary(-            epv: nil, eInput: kwh, eOutput: kwh,-            eCharge: nil, eDischarge: nil,-            socLow: nil, socLowTime: nil,-            offpeakGridImportKwh: 0, offpeakGridExportKwh: nil-        )+        let pricing = [plan(start: "2026-04-01", end: nil, rate: rate, feedIn: rate, savings: rate)]+        let summary = summary(eInput: kwh, eOutput: kwh, offpeakKwh: 0)         let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))         #expect(approximately(costs.peakImportsCost, rate * kwh))         #expect(approximately(costs.solarFeedInIncome, rate * kwh))@@ -107,14 +116,9 @@ struct PeriodCostsTests {      @Test(arguments: [0.0, 1.0, 10.0, 100.0, 1000.0])     func zeroRateProducesZeroCost(kwh: Double) throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: 0, feedIn: 0, offPeak: 0)]-        let summary = DaySummary(-            epv: nil, eInput: kwh, eOutput: kwh,-            eCharge: nil, eDischarge: nil,-            socLow: nil, socLowTime: nil,-            offpeakGridImportKwh: 0, offpeakGridExportKwh: nil-        )-        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        let pricing = [plan(start: "2026-04-01", end: nil, rate: 0, feedIn: 0, savings: 0)]+        let costs = try #require(summary(eInput: kwh, eOutput: kwh, offpeakKwh: 0)+            .costs(forDate: "2026-04-15", in: pricing))         #expect(costs.peakImportsCost == 0)         #expect(costs.solarFeedInIncome == 0)         #expect(costs.offPeakSavings == 0)@@ -123,14 +127,9 @@ struct PeriodCostsTests {      @Test(arguments: [0.0, 0.05, 0.30, 1.0, 9.99])     func zeroKwhProducesZeroCost(rate: Double) throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: rate, feedIn: rate, offPeak: rate)]-        let summary = DaySummary(-            epv: nil, eInput: 0, eOutput: 0,-            eCharge: nil, eDischarge: nil,-            socLow: nil, socLowTime: nil,-            offpeakGridImportKwh: 0, offpeakGridExportKwh: nil-        )-        let costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        let pricing = [plan(start: "2026-04-01", end: nil, rate: rate, feedIn: rate, savings: rate)]+        let costs = try #require(summary(eInput: 0, eOutput: 0, offpeakKwh: 0)+            .costs(forDate: "2026-04-15", in: pricing))         #expect(costs.peakImportsCost == 0)         #expect(costs.solarFeedInIncome == 0)         #expect(costs.offPeakSavings == 0)@@ -142,63 +141,67 @@ struct PeriodCostsTests {         (0.0001, 1000.0, 0.5)     ])     func scalingRateScalesCostProportionally(rate: Double, kwh: Double, scale: Double) throws {-        let pricing = [period(start: "2026-04-01", end: nil, peak: rate, feedIn: 0, offPeak: 0)]-        let scaledPricing = [period(start: "2026-04-01", end: nil, peak: rate * scale, feedIn: 0, offPeak: 0)]-        let summary = DaySummary(-            epv: nil, eInput: kwh, eOutput: 0,-            eCharge: nil, eDischarge: nil,-            socLow: nil, socLowTime: nil,-            offpeakGridImportKwh: 0, offpeakGridExportKwh: nil-        )-        let base = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+        let base = [plan(start: "2026-04-01", end: nil, rate: rate, feedIn: 0, savings: 0)]+        let scaledPricing = [plan(start: "2026-04-01", end: nil, rate: rate * scale, feedIn: 0, savings: 0)]+        let summary = summary(eInput: kwh, eOutput: 0, offpeakKwh: 0)+        let baseCosts = try #require(summary.costs(forDate: "2026-04-15", in: base))         let scaled = try #require(summary.costs(forDate: "2026-04-15", in: scaledPricing))-        #expect(approximately(scaled.peakImportsCost, base.peakImportsCost * scale))+        #expect(approximately(scaled.peakImportsCost, baseCosts.peakImportsCost * scale))     } -    // MARK: - Overlap symmetry (property-based)+    // MARK: - Overlap symmetry (half-open, Decision 5)      @Test(arguments: [-        ("2026-01-01", "2026-06-30", "2026-04-01", "2026-12-31"),-        ("2026-01-01", "2026-06-30", "2026-07-01", "2026-12-31"),-        ("2026-01-01", "2026-12-31", "2026-04-01", "2026-04-30"),-        ("2026-01-01", "2026-06-30", "2026-06-30", "2026-12-31") // single-day overlap+        ("2026-01-01", "2026-07-01", "2026-04-01", "2027-01-01"),+        ("2026-01-01", "2026-07-01", "2026-07-01", "2027-01-01"),+        ("2026-01-01", "2027-01-01", "2026-04-01", "2026-05-01"),+        ("2026-01-01", "2026-07-01", "2026-06-30", "2027-01-01")     ])     func overlapsIsSymmetric(aStart: String, aEnd: String, bStart: String, bEnd: String) throws {-        let alpha = period(id: "a", start: aStart, end: aEnd, peak: 0, feedIn: 0, offPeak: 0)-        let beta = period(id: "b", start: bStart, end: bEnd, peak: 0, feedIn: 0, offPeak: 0)+        let alpha = plan(id: "a", start: aStart, end: aEnd, rate: 0, feedIn: 0, savings: 0)+        let beta = plan(id: "b", start: bStart, end: bEnd, rate: 0, feedIn: 0, savings: 0)         #expect(rangesOverlap(alpha, beta) == rangesOverlap(beta, alpha))     } +    @Test+    func abuttingRangesDoNotOverlapUnderExclusiveEnds() throws {+        // The whole point of Decision 5: "old ends 2026-08-01, new starts+        // 2026-08-01" is a clean succession, not an overlap.+        let alpha = plan(id: "a", start: "2026-01-01", end: "2026-08-01", rate: 0, feedIn: 0, savings: 0)+        let beta = plan(id: "b", start: "2026-08-01", end: nil, rate: 0, feedIn: 0, savings: 0)+        #expect(!rangesOverlap(alpha, beta))+        #expect(!rangesOverlap(beta, alpha))+    }+     @Test(arguments: [-        ("2026-01-01", "2026-06-30", "2026-04-01"),+        ("2026-01-01", "2026-07-01", "2026-04-01"),         ("2026-01-01", nil, "2026-04-01"),-        ("2026-01-01", "2026-06-30", "2026-06-30")+        ("2026-01-01", "2026-07-01", "2026-07-01")     ])     func overlapsWithOpenEnded(aStart: String, aEnd: String?, bStart: String) throws {-        // The right-hand range is open-ended; if its start is on or before-        // the left's end, the two overlap (and the relation is symmetric).-        let alpha = period(id: "a", start: aStart, end: aEnd, peak: 0, feedIn: 0, offPeak: 0)-        let beta = period(id: "b", start: bStart, end: nil, peak: 0, feedIn: 0, offPeak: 0)+        let alpha = plan(id: "a", start: aStart, end: aEnd, rate: 0, feedIn: 0, savings: 0)+        let beta = plan(id: "b", start: bStart, end: nil, rate: 0, feedIn: 0, savings: 0)         #expect(rangesOverlap(alpha, beta) == rangesOverlap(beta, alpha))     } -    // MARK: - helpers+    // MARK: - Helpers -    private func period(+    private func plan(         id: String = "p",         start: String,         end: String?,-        peak: Double,+        rate: Double,         feedIn: Double,-        offPeak: Double-    ) -> PricingPeriod {-        PricingPeriod(+        savings: Double+    ) -> PricingPlan {+        PricingPlan(             id: id,             startDate: start,             endDate: end,-            peakRate: peak,+            defaultRate: rate,+            windows: [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)],             feedInRate: feedIn,-            offPeakSavingsRate: offPeak,+            savingsReferenceRate: savings,             createdAt: Date(timeIntervalSince1970: 1),             updatedAt: Date(timeIntervalSince1970: 1)         )@@ -215,14 +218,24 @@ struct PeriodCostsTests {         )     } +    private func summary(eInput: Double, eOutput: Double, offpeakKwh: Double?) -> DaySummary {+        DaySummary(+            epv: nil, eInput: eInput, eOutput: eOutput,+            eCharge: nil, eDischarge: nil,+            socLow: nil, socLowTime: nil,+            offpeakGridImportKwh: offpeakKwh, offpeakGridExportKwh: nil+        )+    }+     private func approximately(_ lhs: Double, _ rhs: Double, tolerance: Double = 1e-6) -> Bool {         abs(lhs - rhs) < tolerance     } -    /// Free function so we can test the relation directly.-    private func rangesOverlap(_ left: PricingPeriod, _ right: PricingPeriod) -> Bool {+    /// Half-open interval intersection — the relation the server's overlap+    /// check now uses (Decision 5).+    private func rangesOverlap(_ left: PricingPlan, _ right: PricingPlan) -> Bool {         let leftEnd = left.endDate ?? "9999-12-31"         let rightEnd = right.endDate ?? "9999-12-31"-        return left.startDate <= rightEnd && right.startDate <= leftEnd+        return left.startDate < rightEnd && right.startDate < leftEnd     } }
Flux/Packages/FluxCore/Tests/FluxCoreTests/PlanSegmentsVectorTests.swift Added +107 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PlanSegmentsVectorTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PlanSegmentsVectorTests.swiftnew file mode 100644index 0000000..0be3629--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PlanSegmentsVectorTests.swift@@ -0,0 +1,107 @@+import Foundation+import Testing+@testable import FluxCore++/// Segmentation exists in both Go (`plan.Segments`) and Swift. The shared+/// vectors in `internal/api/testdata/pricing_segments.json` are the pin that+/// keeps the two identical — the `note_lengths.json` pattern.+@Suite+struct PlanSegmentsVectorTests {+    @Test+    func segmentationMatchesTheSharedVectors() throws {+        let vectors = try Self.loadVectors()+        #expect(!vectors.isEmpty)++        for vector in vectors {+            let segments = vector.plan.asPlan().segments+            #expect(+                segments.count == vector.segments.count,+                "\(vector.name): expected \(vector.segments.count) segments, got \(segments.count)"+            )+            guard segments.count == vector.segments.count else { continue }+            for (index, want) in vector.segments.enumerated() {+                let got = segments[index]+                #expect(got.start == want.start, "\(vector.name) segment \(index) start")+                #expect(got.end == want.end, "\(vector.name) segment \(index) end")+                #expect(got.free == want.free, "\(vector.name) segment \(index) free")+                #expect(got.rate == want.rate, "\(vector.name) segment \(index) rate")+            }+        }+    }++    @Test+    func segmentsAlwaysTileTheWholeDay() throws {+        for vector in try Self.loadVectors() {+            let segments = vector.plan.asPlan().segments+            #expect(segments.first?.start == "00:00", "\(vector.name) starts at midnight")+            #expect(segments.last?.end == "24:00", "\(vector.name) ends at end of day")+            for index in 1 ..< segments.count {+                #expect(+                    segments[index - 1].end == segments[index].start,+                    "\(vector.name) segment \(index) abuts its predecessor"+                )+            }+        }+    }++    @Test+    func ratedSegmentsDropTheFreeBandOnly() throws {+        for vector in try Self.loadVectors() {+            let plan = vector.plan.asPlan()+            #expect(plan.ratedSegments == plan.segments.filter { !$0.free }, "\(vector.name)")+        }+    }++    // MARK: - Vector fixtures++    private struct Vector: Decodable {+        let name: String+        let plan: VectorPlan+        let segments: [VectorSegment]+    }++    private struct VectorPlan: Decodable {+        let defaultRate: Double+        let windows: [PlanWindow]+        let feedInRate: Double+        let savingsReferenceRate: Double?++        func asPlan() -> PricingPlan {+            PricingPlan(+                id: "vector",+                startDate: "2026-01-01",+                endDate: nil,+                defaultRate: defaultRate,+                windows: windows,+                feedInRate: feedInRate,+                savingsReferenceRate: savingsReferenceRate,+                createdAt: Date(timeIntervalSince1970: 0),+                updatedAt: Date(timeIntervalSince1970: 0)+            )+        }+    }++    private struct VectorSegment: Decodable {+        let start: String+        let end: String+        let free: Bool+        let rate: Double+    }++    private static func loadVectors(file: String = #filePath) throws -> [Vector] {+        try JSONDecoder().decode([Vector].self, from: Data(contentsOf: vectorURL(named: "pricing_segments.json", file: file)))+    }+}++/// `#filePath` is `<repo>/Flux/Packages/FluxCore/Tests/FluxCoreTests/<file>.swift`;+/// walk up to the repo root so both Go and Swift read the same fixture file.+func vectorURL(named name: String, file: String) -> URL {+    URL(fileURLWithPath: file)+        .deletingLastPathComponent()  // FluxCoreTests/+        .deletingLastPathComponent()  // Tests/+        .deletingLastPathComponent()  // FluxCore/+        .deletingLastPathComponent()  // Packages/+        .deletingLastPathComponent()  // Flux/+        .deletingLastPathComponent()  // repo root+        .appendingPathComponent("internal/api/testdata/\(name)")+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPeriodTests.swift Deleted +0 / -187
Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPlanDraftTests.swift Added +225 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPlanDraftTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPlanDraftTests.swiftnew file mode 100644index 0000000..854cf9f--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPlanDraftTests.swift@@ -0,0 +1,225 @@+import Foundation+import Testing+@testable import FluxCore++/// Client-side validation must mirror the server rules (AC 6.4), so every+/// case here has a counterpart in `internal/plan`'s Validate table.+@Suite+struct PricingPlanDraftTests {+    @Test+    func validDraftPassesValidation() {+        #expect(makeDraft().validate() == nil)+    }++    @Test+    func planWithoutWindowsIsValid() {+        var draft = makeDraft()+        draft.windows = []+        draft.savingsReferenceRate = nil+        #expect(draft.validate() == nil)+    }++    @Test+    func malformedDatesAreRejected() {+        var draft = makeDraft()+        draft.startDate = "not-a-date"+        #expect(draft.validate() == .invalidStartDate)++        draft = makeDraft()+        draft.endDate = "2026-02-30"+        #expect(draft.validate() == .invalidEndDate)+    }++    @Test+    func endDateMustBeStrictlyAfterStartDate() {+        var draft = makeDraft()+        draft.endDate = "2026-07-31"+        #expect(draft.validate() == .invertedDates)++        // Exclusive ends make endDate == startDate a plan that prices no days.+        draft = makeDraft()+        draft.endDate = draft.startDate+        #expect(draft.validate() == .invertedDates)+    }++    @Test+    func openEndedDraftSkipsTheEndDateRules() {+        var draft = makeDraft()+        draft.endDate = nil+        #expect(draft.validate() == nil)+    }++    @Test+    func bandBoundariesMustBeParseableAndOrdered() {+        var draft = makeDraft()+        draft.windows = [PlanWindow(start: "25:00", end: "26:00", free: false, rate: 0.2)]+        #expect(draft.validate() == .bandWindowInvalid)++        draft.windows = [PlanWindow(start: "10:00", end: "9:00", free: false, rate: 0.2)]+        #expect(draft.validate() == .bandWindowInvalid)++        draft.windows = [PlanWindow(start: "15:00", end: "15:00", free: false, rate: 0.2)]+        #expect(draft.validate() == .bandWindowInvalid)+    }++    @Test+    func endOfDayBoundaryIsAccepted() {+        var draft = makeDraft()+        draft.windows = [PlanWindow(start: "18:00", end: "24:00", free: false, rate: 0.2)]+        draft.savingsReferenceRate = nil+        #expect(draft.validate() == nil)+    }++    @Test+    func overlappingWindowsAreRejected() {+        var draft = makeDraft()+        draft.windows = [+            PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+            PlanWindow(start: "14:00", end: "18:00", free: false, rate: 0.2)+        ]+        #expect(draft.validate() == .bandOverlap)+    }++    @Test+    func abuttingWindowsDoNotCountAsOverlap() {+        var draft = makeDraft()+        draft.windows = [+            PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+            PlanWindow(start: "15:00", end: "18:00", free: false, rate: 0.2)+        ]+        #expect(draft.validate() == nil)+    }++    @Test+    func atMostOneFreeWindowIsAllowed() {+        var draft = makeDraft()+        draft.windows = [+            PlanWindow(start: "10:00", end: "12:00", free: true, rate: nil),+            PlanWindow(start: "13:00", end: "15:00", free: true, rate: nil)+        ]+        #expect(draft.validate() == .multipleFreeBands)+    }++    @Test+    func aFreeWindowSpanningTheWholeDayLeavesNoRatedBand() {+        var draft = makeDraft()+        draft.windows = [PlanWindow(start: "00:00", end: "24:00", free: true, rate: nil)]+        #expect(draft.validate() == .noRatedBand)+    }++    @Test+    func ratedWindowsTilingTheDayAreValidDespiteZeroWidthRemainder() {+        var draft = makeDraft()+        draft.windows = [+            PlanWindow(start: "00:00", end: "10:00", free: false, rate: 0.28),+            PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+            PlanWindow(start: "15:00", end: "24:00", free: false, rate: 0.30)+        ]+        #expect(draft.validate() == nil)+    }++    @Test+    func aFreeWindowRequiresASavingsReferenceRate() {+        var draft = makeDraft()+        draft.savingsReferenceRate = nil+        #expect(draft.validate() == .savingsRateMissing)+    }++    @Test+    func ratesMustBeInRange() {+        var draft = makeDraft()+        draft.defaultRate = 10.5+        #expect(draft.validate() == .rateOutOfRange)++        draft = makeDraft()+        draft.feedInRate = -0.1+        #expect(draft.validate() == .rateOutOfRange)++        draft = makeDraft()+        draft.savingsReferenceRate = 11+        #expect(draft.validate() == .rateOutOfRange)++        draft = makeDraft()+        draft.windows = [PlanWindow(start: "01:00", end: "06:00", free: false, rate: 20)]+        draft.savingsReferenceRate = nil+        #expect(draft.validate() == .rateOutOfRange)+    }++    @Test+    func ratesMustFitFourDecimalPlaces() {+        var draft = makeDraft()+        draft.defaultRate = 0.12345+        #expect(draft.validate() == .ratePrecision)++        draft = makeDraft()+        draft.windows = [PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.123456)]+        draft.savingsReferenceRate = nil+        #expect(draft.validate() == .ratePrecision)+    }++    @Test+    func aFreeWindowsRateIsIgnoredByTheRateRules() {+        var draft = makeDraft()+        // A free window carries no rate by contract, so a stale value on it+        // must not fail validation.+        draft.windows = [PlanWindow(start: "10:00", end: "15:00", free: true, rate: 99.12345)]+        #expect(draft.validate() == nil)+    }++    @Test+    func roundingNormalisesToFourDecimalPlaces() {+        #expect(PricingPlanDraft.roundedToFourDP(0.123456) == 0.1235)+        #expect(PricingPlanDraft.roundedToFourDP(0.35) == 0.35)+    }++    @Test+    func draftFromPlanRoundTripsEveryWritableField() {+        let plan = PricingPlan(+            id: "pp-1",+            startDate: "2026-08-01",+            endDate: "2026-12-01",+            defaultRate: 0.35,+            windows: [+                PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+                PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)+            ],+            feedInRate: 0.05,+            savingsReferenceRate: 0.35,+            createdAt: Date(timeIntervalSince1970: 0),+            updatedAt: Date(timeIntervalSince1970: 0)+        )+        let draft = PricingPlanDraft(plan: plan)+        #expect(draft.startDate == plan.startDate)+        #expect(draft.endDate == plan.endDate)+        #expect(draft.defaultRate == plan.defaultRate)+        #expect(draft.windows == plan.windows)+        #expect(draft.feedInRate == plan.feedInRate)+        #expect(draft.savingsReferenceRate == plan.savingsReferenceRate)+    }++    @Test+    func draftEncodesTheBandWireShape() throws {+        let encoder = JSONEncoder()+        encoder.outputFormatting = .sortedKeys+        let data = try encoder.encode(makeDraft())+        let json = String(decoding: data, as: UTF8.self)+        #expect(json.contains("\"defaultRate\":0.35"))+        #expect(json.contains("\"windows\""))+        #expect(json.contains("\"savingsReferenceRate\":0.35"))+        // The legacy three-rate fields must not appear — the server rejects+        // that shape with `legacy_shape` (AC 7.3).+        #expect(!json.contains("peakRate"))+        #expect(!json.contains("offPeakSavingsRate"))+    }++    private func makeDraft() -> PricingPlanDraft {+        PricingPlanDraft(+            startDate: "2026-08-01",+            endDate: "2026-12-01",+            defaultRate: 0.35,+            windows: [PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil)],+            feedInRate: 0.05,+            savingsReferenceRate: 0.35+        )+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPlanTests.swift Added +202 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPlanTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPlanTests.swiftnew file mode 100644index 0000000..26f2d46--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPlanTests.swift@@ -0,0 +1,202 @@+import Foundation+import Testing+@testable import FluxCore++@Suite+struct PricingPlanTests {+    @Test+    func decodeFromBackendShape() throws {+        let jsonString = """+        {+          "id": "pp-1",+          "startDate": "2026-08-01",+          "endDate": "2026-12-01",+          "defaultRate": 0.35,+          "windows": [+            { "start": "10:00", "end": "15:00", "free": true },+            { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+          ],+          "feedInRate": 0.05,+          "savingsReferenceRate": 0.35,+          "createdAt": "2026-07-19T08:00:00Z",+          "updatedAt": "2026-07-19T08:00:00Z"+        }+        """+        let plan = try jsonDecoder().decode(PricingPlan.self, from: Data(jsonString.utf8))+        #expect(plan.id == "pp-1")+        #expect(plan.startDate == "2026-08-01")+        #expect(plan.endDate == "2026-12-01")+        #expect(plan.defaultRate == 0.35)+        #expect(plan.feedInRate == 0.05)+        #expect(plan.savingsReferenceRate == 0.35)+        #expect(plan.windows.count == 2)+        #expect(plan.windows[0] == PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil))+        #expect(plan.windows[1] == PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28))+    }++    @Test+    func decodeOpenEndedPlanWithoutEndDateOrSavingsRate() throws {+        let jsonString = """+        {+          "id": "pp-open",+          "startDate": "2026-07-01",+          "defaultRate": 0.30,+          "windows": [],+          "feedInRate": 0.06,+          "createdAt": "2026-07-01T00:00:00Z",+          "updatedAt": "2026-07-01T00:00:00Z"+        }+        """+        let plan = try jsonDecoder().decode(PricingPlan.self, from: Data(jsonString.utf8))+        #expect(plan.endDate == nil)+        #expect(plan.savingsReferenceRate == nil)+        #expect(plan.windows.isEmpty)+    }++    @Test+    func encodeRoundTripPreservesFields() throws {+        let plan = PricingPlan(+            id: "pp-1",+            startDate: "2026-08-01",+            endDate: nil,+            defaultRate: 0.3512,+            windows: [PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil)],+            feedInRate: 0.05,+            savingsReferenceRate: 0.35,+            createdAt: Date(timeIntervalSince1970: 1_715_000_000),+            updatedAt: Date(timeIntervalSince1970: 1_715_100_000)+        )+        let encoded = try jsonEncoder().encode(plan)+        let decoded = try jsonDecoder().decode(PricingPlan.self, from: encoded)+        #expect(decoded == plan)+    }++    // MARK: - covers(date:) — exclusive end (Decision 5)++    @Test+    func coversIsInclusiveOfStartAndExclusiveOfEnd() {+        let plan = makePlan(id: "p", startDate: "2026-08-01", endDate: "2026-09-01")+        #expect(plan.covers(date: "2026-07-31") == false)+        #expect(plan.covers(date: "2026-08-01"))+        #expect(plan.covers(date: "2026-08-31"))+        // The end date is the switch date and belongs to the successor.+        #expect(plan.covers(date: "2026-09-01") == false)+    }++    @Test+    func openEndedPlanCoversEveryDateFromItsStart() {+        let plan = makePlan(id: "p", startDate: "2026-08-01", endDate: nil)+        #expect(plan.covers(date: "2026-07-31") == false)+        #expect(plan.covers(date: "2026-08-01"))+        #expect(plan.covers(date: "2099-01-01"))+    }++    @Test+    func switchDayBelongsToTheSuccessorAndTheDayBeforeToThePredecessor() {+        let predecessor = makePlan(id: "old", startDate: "2026-01-01", endDate: "2026-08-01")+        let successor = makePlan(id: "new", startDate: "2026-08-01", endDate: nil)+        let plans = [predecessor, successor]++        #expect(PricingPlan.plan(for: "2026-07-31", in: plans)?.id == "old")+        #expect(PricingPlan.plan(for: "2026-08-01", in: plans)?.id == "new")+    }++    @Test+    func planForReturnsNilWhenNoPlanCoversTheDate() {+        let plans = [makePlan(id: "p", startDate: "2026-08-01", endDate: "2026-09-01")]+        #expect(PricingPlan.plan(for: "2026-09-02", in: plans) == nil)+    }++    // MARK: - Free window++    @Test+    func freeWindowIsTheFreeSegmentOfThePlan() {+        let plan = makePlan(+            id: "p",+            startDate: "2026-08-01",+            endDate: nil,+            windows: [+                PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+                PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)+            ]+        )+        #expect(plan.freeWindow?.start == "10:00")+        #expect(plan.freeWindow?.end == "15:00")+    }++    @Test+    func freeWindowIsNilWhenThePlanHasNoFreeBand() {+        let plan = makePlan(id: "p", startDate: "2026-08-01", endDate: nil, windows: [])+        #expect(plan.freeWindow == nil)+    }++    @Test+    func freeWindowForDateUsesThePlanPricingThatDate() {+        let predecessor = makePlan(+            id: "old",+            startDate: "2026-01-01",+            endDate: "2026-08-01",+            windows: [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)]+        )+        let successor = makePlan(+            id: "new",+            startDate: "2026-08-01",+            endDate: nil,+            windows: [PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil)]+        )+        let plans = [predecessor, successor]++        #expect(PricingPlan.freeWindow(for: "2026-07-31", in: plans)?.start == "11:00")+        #expect(PricingPlan.freeWindow(for: "2026-08-01", in: plans)?.start == "10:00")+        #expect(PricingPlan.freeWindow(for: "2025-01-01", in: plans) == nil)+    }++    // MARK: - Band time parsing (mirrors plan.ParseBandTime, Q34)++    @Test+    func bandTimeParsingAcceptsEndOfDayAndRejectsMalformedInput() {+        #expect(PlanWindow.parseBandTime("00:00") == 0)+        #expect(PlanWindow.parseBandTime("10:30") == 630)+        // 24:00 is a valid end-of-day boundary; ParseOffpeakWindow would reject it.+        #expect(PlanWindow.parseBandTime("24:00") == 1440)+        #expect(PlanWindow.parseBandTime("24:01") == nil)+        #expect(PlanWindow.parseBandTime("25:00") == nil)+        #expect(PlanWindow.parseBandTime("10:60") == nil)+        #expect(PlanWindow.parseBandTime("1:00") == nil)+        #expect(PlanWindow.parseBandTime("10-00") == nil)+        #expect(PlanWindow.parseBandTime("aa:bb") == nil)+    }++    // MARK: - Helpers++    private func makePlan(+        id: String,+        startDate: String,+        endDate: String?,+        windows: [PlanWindow] = []+    ) -> PricingPlan {+        PricingPlan(+            id: id,+            startDate: startDate,+            endDate: endDate,+            defaultRate: 0.35,+            windows: windows,+            feedInRate: 0.05,+            savingsReferenceRate: windows.contains(where: \.free) ? 0.35 : nil,+            createdAt: Date(timeIntervalSince1970: 0),+            updatedAt: Date(timeIntervalSince1970: 0)+        )+    }++    private func jsonEncoder() -> JSONEncoder {+        let enc = JSONEncoder()+        enc.dateEncodingStrategy = .iso8601+        return enc+    }++    private func jsonDecoder() -> JSONDecoder {+        let dec = JSONDecoder()+        dec.dateDecodingStrategy = .iso8601+        return dec+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingServiceTests.swift Modified +59 / -55
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingServiceTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingServiceTests.swiftindex 98cd5c4..c357cc2 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingServiceTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingServiceTests.swift@@ -7,17 +7,17 @@ struct PricingServiceTests {     @Test     func refreshLoadsListAndSortsAscending() async throws {         let api = MockPricingAPIClient()-        let later = makePeriod(id: "later", start: "2026-07-01", end: nil)-        let earlier = makePeriod(id: "earlier", start: "2026-01-01", end: "2026-06-30")-        api.periodsToReturn = [later, earlier]+        let later = makePlan(id: "later", start: "2026-07-01", end: nil)+        let earlier = makePlan(id: "earlier", start: "2026-01-01", end: "2026-07-01")+        api.plansToReturn = [later, earlier]         let svc = PricingService()         svc.bind(apiClient: api)          try await svc.refresh() -        #expect(svc.periods.count == 2)-        #expect(svc.periods.first?.id == "earlier")-        #expect(svc.periods.last?.id == "later")+        #expect(svc.plans.count == 2)+        #expect(svc.plans.first?.id == "earlier")+        #expect(svc.plans.last?.id == "later")         #expect(svc.lastError == nil)     } @@ -43,16 +43,17 @@ struct PricingServiceTests {         let svc = PricingService()         svc.bind(apiClient: api) -        let draft = PricingPeriodDraft(+        let draft = PricingPlanDraft(             startDate: "2026-08-01",             endDate: nil,-            peakRate: 0.30,+            defaultRate: 0.30,+            windows: [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)],             feedInRate: 0.06,-            offPeakSavingsRate: 0.12+            savingsReferenceRate: 0.12         )         let created = try await svc.create(draft) -        #expect(svc.periods.contains(where: { $0.id == created.id }))+        #expect(svc.plans.contains(where: { $0.id == created.id }))          // Wait for fire-and-forget refetch to complete.         try await waitForRefetch(api: api, expected: 1)@@ -62,55 +63,55 @@ struct PricingServiceTests {     @Test     func updateFoldsUpdatedRowIntoLocalList() async throws {         let api = MockPricingAPIClient()-        let existing = makePeriod(id: "pp-1", start: "2026-01-01", end: "2026-06-30", peakRate: 0.28)-        api.periodsToReturn = [existing]+        let existing = makePlan(id: "pp-1", start: "2026-01-01", end: "2026-07-01", defaultRate: 0.28)+        api.plansToReturn = [existing]         let svc = PricingService()         svc.bind(apiClient: api)         try await svc.refresh() -        let draft = PricingPeriodDraft(period: existing).with(peakRate: 0.32)+        let draft = PricingPlanDraft(plan: existing).with(defaultRate: 0.32)         let updated = try await svc.update(id: existing.id, draft)-        #expect(updated.peakRate == 0.32)-        #expect(svc.periods.first?.peakRate == 0.32)+        #expect(updated.defaultRate == 0.32)+        #expect(svc.plans.first?.defaultRate == 0.32)     }      @Test     func deleteRemovesRowFromLocalList() async throws {         let api = MockPricingAPIClient()-        let existing = makePeriod(id: "pp-1", start: "2026-01-01", end: "2026-06-30")-        api.periodsToReturn = [existing]+        let existing = makePlan(id: "pp-1", start: "2026-01-01", end: "2026-07-01")+        api.plansToReturn = [existing]         let svc = PricingService()         svc.bind(apiClient: api)         try await svc.refresh()          try await svc.delete(id: existing.id)-        #expect(svc.periods.isEmpty)+        #expect(svc.plans.isEmpty)     }      @Test     func replaceOpenEndedFoldsBothChanges() async throws {         let api = MockPricingAPIClient()-        let open = makePeriod(id: "pp-open", start: "2026-01-01", end: nil)-        api.periodsToReturn = [open]+        let open = makePlan(id: "pp-open", start: "2026-01-01", end: nil)+        api.plansToReturn = [open]         let svc = PricingService()         svc.bind(apiClient: api)         try await svc.refresh()         // The server returns the new row only — the service must trigger alpha         // refetch so the local list reflects the closing-row's new endDate.-        let newOpen = makePeriod(id: "pp-new", start: "2026-08-01", end: nil)-        let closed = makePeriod(id: "pp-open", start: "2026-01-01", end: "2026-07-31")-        api.replaceOpenEndedResult = ReplaceOpenEndedResult(closing: closed, newPeriod: newOpen)+        let newOpen = makePlan(id: "pp-new", start: "2026-08-01", end: nil)+        let closed = makePlan(id: "pp-open", start: "2026-01-01", end: "2026-08-01")+        api.replaceOpenEndedResult = ReplaceOpenEndedResult(closing: closed, newPlan: newOpen)         // After the refetch the API returns the final state.-        api.periodsToReturn = [closed, newOpen]+        api.plansToReturn = [closed, newOpen] -        let draft = PricingPeriodDraft(period: newOpen)+        let draft = PricingPlanDraft(plan: newOpen)         let result = try await svc.replaceOpenEnded(closingId: "pp-open", with: draft)         #expect(result.id == "pp-new")         try await waitForRefetch(api: api, expected: 2)-        let finalPeriods = svc.periods-        #expect(finalPeriods.count == 2)-        #expect(finalPeriods.first?.id == "pp-open")-        #expect(finalPeriods.first?.endDate == "2026-07-31")+        let finalPlans = svc.plans+        #expect(finalPlans.count == 2)+        #expect(finalPlans.first?.id == "pp-open")+        #expect(finalPlans.first?.endDate == "2026-08-01")     }      @Test@@ -148,24 +149,25 @@ struct PricingServiceTests {         Issue.record("refetch did not complete in time (got \(api.fetchCallCount), expected \(expected))")     } -    private func makePeriod(id: String, start: String, end: String?, peakRate: Double = 0.30) -> PricingPeriod {-        PricingPeriod(+    private func makePlan(id: String, start: String, end: String?, defaultRate: Double = 0.30) -> PricingPlan {+        PricingPlan(             id: id,             startDate: start,             endDate: end,-            peakRate: peakRate,+            defaultRate: defaultRate,+            windows: [PlanWindow(start: "11:00", end: "14:00", free: true, rate: nil)],             feedInRate: 0.05,-            offPeakSavingsRate: 0.12,+            savingsReferenceRate: 0.12,             createdAt: Date(timeIntervalSince1970: 1),             updatedAt: Date(timeIntervalSince1970: 1)         )     } } -private extension PricingPeriodDraft {-    func with(peakRate: Double) -> PricingPeriodDraft {+private extension PricingPlanDraft {+    func with(defaultRate: Double) -> PricingPlanDraft {         var copy = self-        copy.peakRate = peakRate+        copy.defaultRate = defaultRate         return copy     } }@@ -173,7 +175,7 @@ private extension PricingPeriodDraft { // MARK: - Test doubles  final class MockPricingAPIClient: FluxAPIClient, @unchecked Sendable {-    var periodsToReturn: [PricingPeriod] = []+    var plansToReturn: [PricingPlan] = []     var fetchError: FluxAPIError?     var fetchCallCount = 0     var replaceOpenEndedResult: ReplaceOpenEndedResult?@@ -189,54 +191,56 @@ final class MockPricingAPIClient: FluxAPIClient, @unchecked Sendable {         NoteResponse(date: date, text: "", updatedAt: nil)     } -    func fetchPricing() async throws -> [PricingPeriod] {+    func fetchPricing() async throws -> [PricingPlan] {         fetchCallCount += 1         if let fetchError { throw fetchError }-        return periodsToReturn+        return plansToReturn     } -    func createPricing(_ draft: PricingPeriodDraft) async throws -> PricingPeriod {+    func createPricing(_ draft: PricingPlanDraft) async throws -> PricingPlan {         let now = Date()-        let period = PricingPeriod(+        let plan = PricingPlan(             id: "mock-\(UUID().uuidString)",             startDate: draft.startDate,             endDate: draft.endDate,-            peakRate: draft.peakRate,+            defaultRate: draft.defaultRate,+            windows: draft.windows,             feedInRate: draft.feedInRate,-            offPeakSavingsRate: draft.offPeakSavingsRate,+            savingsReferenceRate: draft.savingsReferenceRate,             createdAt: now,             updatedAt: now         )-        periodsToReturn.append(period)-        return period+        plansToReturn.append(plan)+        return plan     } -    func updatePricing(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod {-        guard let idx = periodsToReturn.firstIndex(where: { $0.id == id }) else {+    func updatePricing(id: String, _ draft: PricingPlanDraft) async throws -> PricingPlan {+        guard let idx = plansToReturn.firstIndex(where: { $0.id == id }) else {             throw FluxAPIError.notFound         }         let now = Date()-        let period = PricingPeriod(+        let plan = PricingPlan(             id: id,             startDate: draft.startDate,             endDate: draft.endDate,-            peakRate: draft.peakRate,+            defaultRate: draft.defaultRate,+            windows: draft.windows,             feedInRate: draft.feedInRate,-            offPeakSavingsRate: draft.offPeakSavingsRate,-            createdAt: periodsToReturn[idx].createdAt,+            savingsReferenceRate: draft.savingsReferenceRate,+            createdAt: plansToReturn[idx].createdAt,             updatedAt: now         )-        periodsToReturn[idx] = period-        return period+        plansToReturn[idx] = plan+        return plan     }      func deletePricing(id: String) async throws {-        periodsToReturn.removeAll { $0.id == id }+        plansToReturn.removeAll { $0.id == id }     }      func replaceOpenEndedPricing(         closingId _: String,-        with _: PricingPeriodDraft+        with _: PricingPlanDraft     ) async throws -> ReplaceOpenEndedResult {         if let result = replaceOpenEndedResult { return result }         throw FluxAPIError.serverError
Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientPricingTests.swift Modified +161 / -133
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientPricingTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientPricingTests.swiftindex 26155df..fe93b76 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientPricingTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientPricingTests.swift@@ -8,7 +8,7 @@ struct URLSessionAPIClientPricingTests {     // MARK: - List      @Test-    func fetchPricingDecodesArray() async throws {+    func fetchPricingDecodesTheBandShape() async throws {         let session = makeSession()         PricingMockURLProtocol.requestHandler = { request in             let url = try #require(request.url)@@ -21,19 +21,24 @@ struct URLSessionAPIClientPricingTests {                 {                   "id": "pp-1",                   "startDate": "2026-01-01",-                  "endDate": "2026-06-30",-                  "peakRate": 0.2873,+                  "endDate": "2026-08-01",+                  "defaultRate": 0.2873,+                  "windows": [{ "start": "11:00", "end": "14:00", "free": true }],                   "feedInRate": 0.05,-                  "offPeakSavingsRate": 0.12,+                  "savingsReferenceRate": 0.2873,                   "createdAt": "2026-01-01T00:00:00Z",                   "updatedAt": "2026-01-01T00:00:00Z"                 },                 {                   "id": "pp-2",-                  "startDate": "2026-07-01",-                  "peakRate": 0.30,+                  "startDate": "2026-08-01",+                  "defaultRate": 0.35,+                  "windows": [+                    { "start": "10:00", "end": "15:00", "free": true },+                    { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+                  ],                   "feedInRate": 0.06,-                  "offPeakSavingsRate": 0.12,+                  "savingsReferenceRate": 0.35,                   "createdAt": "2026-07-01T00:00:00Z",                   "updatedAt": "2026-07-01T00:00:00Z"                 }@@ -43,11 +48,17 @@ struct URLSessionAPIClientPricingTests {             return (response, Data(body.utf8))         }         let client = makeClient(session: session)-        let periods = try await client.fetchPricing()+        let plans = try await client.fetchPricing()++        #expect(plans.count == 2)+        #expect(plans[0].id == "pp-1")+        // The exclusive end and the successor's start are the same literal date.+        #expect(plans[0].endDate == "2026-08-01")+        #expect(plans[1].startDate == "2026-08-01")+        #expect(plans[1].endDate == nil)+        #expect(plans[1].windows.count == 2)+        #expect(plans[1].windows[1].rate == 0.28) -        #expect(periods.count == 2)-        #expect(periods[0].id == "pp-1")-        #expect(periods[1].endDate == nil)         let request = try #require(PricingMockURLProtocol.lastRequest)         let requestURL = try #require(request.url)         let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))@@ -67,42 +78,23 @@ struct URLSessionAPIClientPricingTests {             return (response, Data("{\"pricing\": []}".utf8))         }         let client = makeClient(session: session)-        let periods = try await client.fetchPricing()-        #expect(periods.isEmpty)+        #expect(try await client.fetchPricing().isEmpty)     }      // MARK: - Create      @Test-    func createPricingPostsDraftAndDecodesResponse() async throws {+    func createPricingPostsTheBandPayload() async throws {         let session = makeSession()         PricingMockURLProtocol.requestHandler = { request in             let url = try #require(request.url)             let response = HTTPURLResponse(                 url: url, statusCode: 200, httpVersion: nil, headerFields: nil             )!-            let body = """-            {-              "id": "pp-new",-              "startDate": "2026-08-01",-              "peakRate": 0.30,-              "feedInRate": 0.06,-              "offPeakSavingsRate": 0.12,-              "createdAt": "2026-08-01T00:00:00Z",-              "updatedAt": "2026-08-01T00:00:00Z"-            }-            """-            return (response, Data(body.utf8))+            return (response, Data(Self.newPlanBody.utf8))         }-        let draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: nil,-            peakRate: 0.30,-            feedInRate: 0.06,-            offPeakSavingsRate: 0.12-        )         let client = makeClient(session: session)-        let created = try await client.createPricing(draft)+        let created = try await client.createPricing(makeDraft())          #expect(created.id == "pp-new")         let request = try #require(PricingMockURLProtocol.lastRequest)@@ -111,10 +103,21 @@ struct URLSessionAPIClientPricingTests {         let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))         #expect(components.path == "/pricing")         #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")+         let bodyData = try #require(PricingMockURLProtocol.lastRequestBody)         let json = try #require(try JSONSerialization.jsonObject(with: bodyData) as? [String: Any])         #expect(json["startDate"] as? String == "2026-08-01")-        #expect((json["peakRate"] as? NSNumber)?.doubleValue == 0.30)+        #expect((json["defaultRate"] as? NSNumber)?.doubleValue == 0.35)+        #expect((json["savingsReferenceRate"] as? NSNumber)?.doubleValue == 0.35)+        // The legacy three-rate shape is rejected by the server (AC 7.3), so+        // the client must never emit it.+        #expect(json["peakRate"] == nil)++        let windows = try #require(json["windows"] as? [[String: Any]])+        #expect(windows.count == 2)+        #expect(windows[0]["start"] as? String == "10:00")+        #expect(windows[0]["free"] as? Bool == true)+        #expect((windows[1]["rate"] as? NSNumber)?.doubleValue == 0.28)     }      @Test@@ -126,20 +129,13 @@ struct URLSessionAPIClientPricingTests {                 url: url, statusCode: 400, httpVersion: nil, headerFields: nil             )!             let body = """-            {"error": "overlap", "openEndedId": "pp-open-123"}+            {"error": "overlap", "openEndedId": "pp-open-123", "conflictingPricingId": "pp-open-123"}             """             return (response, Data(body.utf8))         }-        let draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: nil,-            peakRate: 0.30,-            feedInRate: 0.06,-            offPeakSavingsRate: 0.12-        )         let client = makeClient(session: session)         do {-            _ = try await client.createPricing(draft)+            _ = try await client.createPricing(makeDraft())             Issue.record("expected overlap error")         } catch let error as FluxAPIError {             guard case let .pricingValidation(reason) = error,@@ -151,13 +147,40 @@ struct URLSessionAPIClientPricingTests {         }     } +    @Test+    func createPricingMapsAnOverlapWithANonOpenEndedPlan() async throws {+        // Only an overlap with the unique open-ended plan can be remediated in+        // one tap, so `openEndedId` is absent for any other conflict.+        let session = makeSession()+        PricingMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(+                url: url, statusCode: 400, httpVersion: nil, headerFields: nil+            )!+            return (response, Data("{\"error\":\"overlap\",\"conflictingPricingId\":\"pp-closed\"}".utf8))+        }+        let client = makeClient(session: session)+        do {+            _ = try await client.createPricing(makeDraft())+            Issue.record("expected overlap error")+        } catch let error as FluxAPIError {+            #expect(error == .pricingValidation(.overlap(openEndedId: nil)))+        }+    }+     @Test     func createPricingMapsAllValidationCodes() async throws {         let cases: [(String, PricingValidationReason)] = [             ("inverted_dates", .invertedDates),             ("rate_precision", .ratePrecision),             ("rate_out_of_range", .rateOutOfRange),-            ("second_open_ended", .secondOpenEnded)+            ("second_open_ended", .secondOpenEnded),+            ("band_window_invalid", .bandWindowInvalid),+            ("band_overlap", .bandOverlap),+            ("multiple_free_bands", .multipleFreeBands),+            ("savings_rate_missing", .savingsRateMissing),+            ("no_rated_band", .noRatedBand),+            ("legacy_shape", .legacyShape)         ]         for (code, expectedReason) in cases {             let session = makeSession()@@ -166,18 +189,11 @@ struct URLSessionAPIClientPricingTests {                 let response = HTTPURLResponse(                     url: url, statusCode: 400, httpVersion: nil, headerFields: nil                 )!-                let body = "{\"error\": \"\(code)\"}"-                return (response, Data(body.utf8))+                return (response, Data("{\"error\": \"\(code)\"}".utf8))             }-            let draft = PricingPeriodDraft(-                startDate: "2026-08-01",-                peakRate: 0.3,-                feedInRate: 0.05,-                offPeakSavingsRate: 0.12-            )             let client = makeClient(session: session)             do {-                _ = try await client.createPricing(draft)+                _ = try await client.createPricing(makeDraft())                 Issue.record("expected \(code)")             } catch let error as FluxAPIError {                 guard case let .pricingValidation(reason) = error else {@@ -189,6 +205,18 @@ struct URLSessionAPIClientPricingTests {         }     } +    @Test+    func everyValidationReasonHasAMessage() {+        let reasons: [PricingValidationReason] = [+            .invertedDates, .overlap(openEndedId: nil), .ratePrecision, .rateOutOfRange,+            .secondOpenEnded, .concurrentWrite, .bandWindowInvalid, .bandOverlap,+            .multipleFreeBands, .savingsRateMissing, .noRatedBand, .legacyShape+        ]+        for reason in reasons {+            #expect(!reason.message.isEmpty, "\(reason)")+        }+    }+     @Test     func createPricingMaps409ToConcurrentWrite() async throws {         let session = makeSession()@@ -197,18 +225,11 @@ struct URLSessionAPIClientPricingTests {             let response = HTTPURLResponse(                 url: url, statusCode: 409, httpVersion: nil, headerFields: nil             )!-            let body = "{\"error\": \"concurrent_open_ended_write\"}"-            return (response, Data(body.utf8))+            return (response, Data("{\"error\": \"concurrent_open_ended_write\"}".utf8))         }-        let draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            peakRate: 0.3,-            feedInRate: 0.05,-            offPeakSavingsRate: 0.12-        )         let client = makeClient(session: session)         do {-            _ = try await client.createPricing(draft)+            _ = try await client.createPricing(makeDraft())             Issue.record("expected concurrent write error")         } catch let error as FluxAPIError {             #expect(error == .pricingValidation(.concurrentWrite))@@ -225,15 +246,9 @@ struct URLSessionAPIClientPricingTests {             )!             return (response, Data())         }-        let draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            peakRate: 0.3,-            feedInRate: 0.05,-            offPeakSavingsRate: 0.12-        )         let client = makeClient(session: session)         do {-            _ = try await client.createPricing(draft)+            _ = try await client.createPricing(makeDraft())             Issue.record("expected unauthorized")         } catch let error as FluxAPIError {             #expect(error == .unauthorized)@@ -250,40 +265,20 @@ struct URLSessionAPIClientPricingTests {             let response = HTTPURLResponse(                 url: url, statusCode: 200, httpVersion: nil, headerFields: nil             )!-            let body = """-            {-              "id": "pp-1",-              "startDate": "2026-01-01",-              "endDate": "2026-06-30",-              "peakRate": 0.31,-              "feedInRate": 0.06,-              "offPeakSavingsRate": 0.12,-              "createdAt": "2026-01-01T00:00:00Z",-              "updatedAt": "2026-05-23T00:00:00Z"-            }-            """-            return (response, Data(body.utf8))+            return (response, Data(Self.newPlanBody.utf8))         }-        let draft = PricingPeriodDraft(-            startDate: "2026-01-01",-            endDate: "2026-06-30",-            peakRate: 0.31,-            feedInRate: 0.06,-            offPeakSavingsRate: 0.12-        )         let client = makeClient(session: session)-        let updated = try await client.updatePricing(id: "pp-1", draft)-        #expect(updated.id == "pp-1")-        #expect(updated.peakRate == 0.31)+        let updated = try await client.updatePricing(id: "pp-new", makeDraft())+        #expect(updated.defaultRate == 0.35)         let request = try #require(PricingMockURLProtocol.lastRequest)         #expect(request.httpMethod == "PUT")         let requestURL = try #require(request.url)         let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))-        #expect(components.path == "/pricing/pp-1")+        #expect(components.path == "/pricing/pp-new")     }      @Test-    func updatePricingMaps404ToNotFoundAsBadRequest() async throws {+    func updatePricingMaps404ToNotFound() async throws {         let session = makeSession()         PricingMockURLProtocol.requestHandler = { request in             let url = try #require(request.url)@@ -292,15 +287,9 @@ struct URLSessionAPIClientPricingTests {             )!             return (response, Data())         }-        let draft = PricingPeriodDraft(-            startDate: "2026-01-01",-            peakRate: 0.3,-            feedInRate: 0.05,-            offPeakSavingsRate: 0.12-        )         let client = makeClient(session: session)         do {-            _ = try await client.updatePricing(id: "pp-missing", draft)+            _ = try await client.updatePricing(id: "pp-missing", makeDraft())             Issue.record("expected notFound")         } catch let error as FluxAPIError {             #expect(error == .notFound)@@ -350,7 +339,7 @@ struct URLSessionAPIClientPricingTests {     // MARK: - Replace open-ended      @Test-    func replaceOpenEndedPricingPostsCombinedPayload() async throws {+    func replaceOpenEndedClosesThePredecessorOnTheSuccessorsStartDate() async throws {         let session = makeSession()         PricingMockURLProtocol.requestHandler = { request in             let url = try #require(request.url)@@ -363,50 +352,63 @@ struct URLSessionAPIClientPricingTests {                 {                   "id": "pp-open",                   "startDate": "2026-01-01",-                  "endDate": "2026-07-31",-                  "peakRate": 0.2873,+                  "endDate": "2026-08-01",+                  "defaultRate": 0.2873,+                  "windows": [{ "start": "11:00", "end": "14:00", "free": true }],                   "feedInRate": 0.05,-                  "offPeakSavingsRate": 0.12,+                  "savingsReferenceRate": 0.2873,                   "createdAt": "2026-01-01T00:00:00Z",                   "updatedAt": "2026-08-01T00:00:00Z"                 },-                {-                  "id": "pp-new",-                  "startDate": "2026-08-01",-                  "peakRate": 0.30,-                  "feedInRate": 0.06,-                  "offPeakSavingsRate": 0.12,-                  "createdAt": "2026-08-01T00:00:00Z",-                  "updatedAt": "2026-08-01T00:00:00Z"-                }+                \(Self.newPlanBody)               ]             }             """             return (response, Data(body.utf8))         }-        let draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            endDate: nil,-            peakRate: 0.30,-            feedInRate: 0.06,-            offPeakSavingsRate: 0.12-        )         let client = makeClient(session: session)-        let result = try await client.replaceOpenEndedPricing(closingId: "pp-open", with: draft)+        let result = try await client.replaceOpenEndedPricing(closingId: "pp-open", with: makeDraft())+         #expect(result.closing.id == "pp-open")-        #expect(result.closing.endDate == "2026-07-31")-        #expect(result.newPeriod.id == "pp-new")-        #expect(result.newPeriod.endDate == nil)+        // Exclusive end: the closing plan's end date IS the successor's start+        // date (AC 2.2), with no ±1 arithmetic anywhere.+        #expect(result.closing.endDate == "2026-08-01")+        #expect(result.newPlan.id == "pp-new")+        #expect(result.newPlan.startDate == "2026-08-01")+        #expect(result.newPlan.endDate == nil)+         let request = try #require(PricingMockURLProtocol.lastRequest)         #expect(request.httpMethod == "POST")         let requestURL = try #require(request.url)         let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))         #expect(components.path == "/pricing/replace-open-ended")+         let bodyData = try #require(PricingMockURLProtocol.lastRequestBody)         let json = try #require(try JSONSerialization.jsonObject(with: bodyData) as? [String: Any])         #expect(json["closingPricingId"] as? String == "pp-open")         let newPeriod = try #require(json["newPeriod"] as? [String: Any])         #expect(newPeriod["startDate"] as? String == "2026-08-01")+        #expect(newPeriod["windows"] != nil)+    }++    @Test+    func replaceOpenEndedMapsLegacyShapeRejection() async throws {+        // The closing row is still the pre-migration three-rate shape (Q32).+        let session = makeSession()+        PricingMockURLProtocol.requestHandler = { request in+            let url = try #require(request.url)+            let response = HTTPURLResponse(+                url: url, statusCode: 400, httpVersion: nil, headerFields: nil+            )!+            return (response, Data("{\"error\":\"legacy_shape\"}".utf8))+        }+        let client = makeClient(session: session)+        do {+            _ = try await client.replaceOpenEndedPricing(closingId: "pp-open", with: makeDraft())+            Issue.record("expected legacyShape")+        } catch let error as FluxAPIError {+            #expect(error == .pricingValidation(.legacyShape))+        }     }      @Test@@ -419,15 +421,9 @@ struct URLSessionAPIClientPricingTests {             )!             return (response, Data("{\"error\":\"concurrent_open_ended_write\"}".utf8))         }-        let draft = PricingPeriodDraft(-            startDate: "2026-08-01",-            peakRate: 0.30,-            feedInRate: 0.06,-            offPeakSavingsRate: 0.12-        )         let client = makeClient(session: session)         do {-            _ = try await client.replaceOpenEndedPricing(closingId: "pp-open", with: draft)+            _ = try await client.replaceOpenEndedPricing(closingId: "pp-open", with: makeDraft())             Issue.record("expected concurrent")         } catch let error as FluxAPIError {             #expect(error == .pricingValidation(.concurrentWrite))@@ -435,9 +431,41 @@ struct URLSessionAPIClientPricingTests {     }      // MARK: - Helpers++    private static let newPlanBody = """+    {+      "id": "pp-new",+      "startDate": "2026-08-01",+      "defaultRate": 0.35,+      "windows": [+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.06,+      "savingsReferenceRate": 0.35,+      "createdAt": "2026-08-01T00:00:00Z",+      "updatedAt": "2026-08-01T00:00:00Z"+    }+    """++    private func makeDraft() -> PricingPlanDraft {+        PricingPlanDraft(+            startDate: "2026-08-01",+            endDate: nil,+            defaultRate: 0.35,+            windows: [+                PlanWindow(start: "10:00", end: "15:00", free: true, rate: nil),+                PlanWindow(start: "01:00", end: "06:00", free: false, rate: 0.28)+            ],+            feedInRate: 0.06,+            savingsReferenceRate: 0.35+        )+    }+     private func makeClient(session: URLSession) -> URLSessionAPIClient {         URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "token", session: session)     }+     private func makeSession() -> URLSession {         let configuration = URLSessionConfiguration.ephemeral         configuration.protocolClasses = [PricingMockURLProtocol.self]
infrastructure/template.yaml Modified +20 / -37
diff --git a/infrastructure/template.yaml b/infrastructure/template.yamlindex 9cfd6c8..e5c5c83 100644--- a/infrastructure/template.yaml+++ b/infrastructure/template.yaml@@ -16,21 +16,11 @@ Parameters:     Type: String     Description: AlphaESS system serial number -  OffPeakWindowStart:-    Type: String-    # No default — HH:MM values must be passed via parameters file or-    # --parameter-overrides. aws cloudformation package re-serializes YAML-    # and strips quotes, causing YAML 1.1 sexagesimal parsing (11:00 → 660).-    AllowedPattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"-    ConstraintDescription: Must be a valid time in HH:MM format (24-hour)-    Description: Off-peak window start time (HH:MM)--  OffPeakWindowEnd:-    Type: String-    # No default — see OffPeakWindowStart comment.-    AllowedPattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"-    ConstraintDescription: Must be a valid time in HH:MM format (24-hour)-    Description: Off-peak window end time (HH:MM)+  # The off-peak window parameters are gone: the free window is a property of+  # the pricing plan that prices each day (time-of-use-pricing Decision 2), so+  # it switches with the plan instead of needing a stack update on the switch+  # date. Deploying this version leaves /flux/offpeak-start and+  # /flux/offpeak-end behind as orphaned SSM parameters; nothing reads them.    SSMPathPrefix:     Type: String@@ -216,6 +206,17 @@ Resources:                   - !GetAtt DevicesTable.Arn                   - !GetAtt SocRulesTable.Arn                   - !GetAtt SocFireStateTable.Arn+              # Pricing is read-only for the poller: the plan is the source of+              # truth for each day's free window (time-of-use-pricing+              # Decision 2), and the Lambda keeps sole write access. Scan is+              # required because ListPricing pages the whole (tiny) table.+              - Effect: Allow+                Action:+                  - dynamodb:Scan+                  - dynamodb:GetItem+                  - dynamodb:Query+                Resource:+                  - !GetAtt PricingTable.Arn         # daily-derived-stats spec, AC 1.11 / 7.3 + Decision 3.         # UpdateItem on DailyEnergyTable is also covered by FluxTaskPolicy         # above; declared again here so the derivedStats writer's IAM@@ -365,22 +366,6 @@ Resources:       Value: !Ref SystemSerialNumber       Description: AlphaESS system serial number -  OffpeakStartParameter:-    Type: AWS::SSM::Parameter-    Properties:-      Name: !Sub "${SSMPathPrefix}/offpeak-start"-      Type: String-      Value: !Ref OffPeakWindowStart-      Description: Off-peak window start time--  OffpeakEndParameter:-    Type: AWS::SSM::Parameter-    Properties:-      Name: !Sub "${SSMPathPrefix}/offpeak-end"-      Type: String-      Value: !Ref OffPeakWindowEnd-      Description: Off-peak window end time-   # --- DynamoDB Tables ---    ReadingsTable:@@ -620,8 +605,6 @@ Resources:           TZ: Australia/Sydney           API_TOKEN_PARAM: !Sub "${SSMPathPrefix}/api-token"           SYSTEM_SERIAL_PARAM: !Sub "${SSMPathPrefix}/serial"-          OFFPEAK_START: !Ref OffPeakWindowStart-          OFFPEAK_END: !Ref OffPeakWindowEnd           TABLE_READINGS: !Ref ReadingsTable           TABLE_DAILY_ENERGY: !Ref DailyEnergyTable           TABLE_DAILY_POWER: !Ref DailyPowerTable@@ -688,10 +671,6 @@ Resources:             - Name: SYSTEM_SERIAL               ValueFrom: !Sub "${SSMPathPrefix}/serial"           Environment:-            - Name: OFFPEAK_START-              Value: !Ref OffPeakWindowStart-            - Name: OFFPEAK_END-              Value: !Ref OffPeakWindowEnd             - Name: AWS_REGION               Value: !Ref AWS::Region             - Name: TABLE_READINGS@@ -704,6 +683,10 @@ Resources:               Value: !Ref SystemTable             - Name: TABLE_OFFPEAK               Value: !Ref OffpeakTable+            # Read-only: each day's free window comes from the plan pricing+            # that day (time-of-use-pricing Decision 2).+            - Name: TABLE_PRICING+              Value: !Ref PricingTable             - Name: TABLE_DEVICES               Value: !Ref DevicesTable             - Name: TABLE_SOC_RULES
internal/api/band_imports_test.go Added +241 / -0
diff --git a/internal/api/band_imports_test.go b/internal/api/band_imports_test.gonew file mode 100644index 0000000..bab6ba6--- /dev/null+++ b/internal/api/band_imports_test.go@@ -0,0 +1,241 @@+package api++import (+	"context"+	"encoding/json"+	"strings"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// This file covers the per-band import split on the read endpoints: stored+// values for past days, a live integration for today, and the requirement+// that /day and /history report the identical numbers (AC 3.4).++// touPlan is the incoming time-of-use plan (Q3). Its rated segments are+// 00:00-01:00, 01:00-06:00, 06:00-10:00 and 15:00-24:00 — the free band and+// the default remainder either side of it.+func touPlan(id string) dynamo.PricingItem {+	return planRow(id, "2000-01-01", nil,+		freeBand("10:00", "15:00"),+		ratedBand("01:00", "06:00", 0.28))+}++func TestDay_PastDayServesStoredBandImports(t *testing.T) {+	// AC 3.5: the captured split outlives the readings TTL, so a past day is+	// served straight from storage.+	now := time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ)+	const date = "2026-04-10"+	stored := []dynamo.BandImportAttr{+		{Start: "00:00", End: "01:00", Kwh: 1.1},+		{Start: "01:00", End: "06:00", Kwh: 5.5},+		{Start: "06:00", End: "10:00", Kwh: 4.4},+		{Start: "15:00", End: "24:00", Kwh: 9.9},+	}+	mr := &mockReader{+		getDailyEnergyFn: func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+			return &dynamo.DailyEnergyItem{+				SysSn: serial, Date: d, EInput: 21,+				BandImports: stored, BandsComputedAt: "2026-04-11T00:05:00Z",+			}, nil+		},+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	var dr DayDetailResponse+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &dr))+	require.NotNil(t, dr.Summary)+	require.Len(t, dr.Summary.BandImports, 4)+	for i, want := range stored {+		assert.Equal(t, want.Start, dr.Summary.BandImports[i].Start)+		assert.Equal(t, want.End, dr.Summary.BandImports[i].End)+		assert.InDelta(t, want.Kwh, dr.Summary.BandImports[i].Kwh, 1e-9)+	}+}++func TestDay_PastDayWithoutStoredSplitOmitsBandImports(t *testing.T) {+	// A pre-feature row has no split; the client falls back to the tier-2 or+	// tier-3 cost path rather than seeing an empty array it might read as+	// "zero import in every band".+	now := time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ)+	mr := &mockReader{+		getDailyEnergyFn: func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+			return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 21}, nil+		},+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": "2026-04-10"}))+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	assert.NotContains(t, resp.Body, "bandImports", "absent, not an empty array")+}++func TestDay_TodayBandImportsIntegratedLive(t *testing.T) {+	// AC 3.4: today has no stored split yet, so it is integrated from+	// readings. A steady 1 kW import makes each band's expected value its+	// elapsed length in hours; the final band is clamped to now.+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	const date = "2026-04-15"+	mr := readerWithReadings(steadyImportReadings(now, 1000))+	mr.getDailyEnergyFn = func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+		return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 16}, nil+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	var dr DayDetailResponse+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &dr))+	require.NotNil(t, dr.Summary)+	want := []BandImport{+		{Start: "00:00", End: "01:00", Kwh: 1},+		{Start: "01:00", End: "06:00", Kwh: 5},+		{Start: "06:00", End: "10:00", Kwh: 4},+		{Start: "15:00", End: "24:00", Kwh: 1}, // 15:00-16:00 elapsed+	}+	require.Len(t, dr.Summary.BandImports, len(want))+	for i, w := range want {+		assert.Equal(t, w.Start, dr.Summary.BandImports[i].Start)+		assert.Equal(t, w.End, dr.Summary.BandImports[i].End)+		assert.InDelta(t, w.Kwh, dr.Summary.BandImports[i].Kwh, 0.05)+	}+	// The free band is not in the list — the flux-offpeak row owns that kWh+	// exclusively (Q31).+	for _, b := range dr.Summary.BandImports {+		assert.NotEqual(t, "10:00", b.Start, "the free band must not appear in bandImports")+	}+}++func TestDayAndHistoryAgreeOnTodaysBandImports(t *testing.T) {+	// AC 3.4 and the project's data-consistency rule: both endpoints derive+	// today's split from the same helper, so the numbers are identical.+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	const date = "2026-04-15"+	readings := steadyImportReadings(now, 1000)+	mr := readerWithReadings(readings)+	mr.getDailyEnergyFn = func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+		return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 16}, nil+	}+	mr.queryDailyEnergyFn = func(_ context.Context, serial, _, _ string) ([]dynamo.DailyEnergyItem, error) {+		return []dynamo.DailyEnergyItem{{SysSn: serial, Date: date, EInput: 16}}, nil+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	dayResp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+	require.NoError(t, err)+	require.Equal(t, 200, dayResp.StatusCode)+	var dr DayDetailResponse+	require.NoError(t, json.Unmarshal([]byte(dayResp.Body), &dr))++	histReq := makeRequest("GET", "/history", "Bearer "+testToken)+	histReq.QueryStringParameters = map[string]string{"days": "1"}+	histResp, err := h.Handle(context.Background(), histReq)+	require.NoError(t, err)+	require.Equal(t, 200, histResp.StatusCode)+	var hr HistoryResponse+	require.NoError(t, json.Unmarshal([]byte(histResp.Body), &hr))++	require.NotNil(t, dr.Summary)+	require.Len(t, hr.Days, 1)+	require.NotEmpty(t, dr.Summary.BandImports)+	assert.Equal(t, dr.Summary.BandImports, hr.Days[0].BandImports,+		"/day and /history must show the identical split for the same day")+}++func TestHistory_PastRowServesStoredBandImports(t *testing.T) {+	now := time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ)+	stored := []dynamo.BandImportAttr{+		{Start: "00:00", End: "01:00", Kwh: 1.1},+		{Start: "01:00", End: "06:00", Kwh: 5.5},+		{Start: "06:00", End: "10:00", Kwh: 4.4},+		{Start: "15:00", End: "24:00", Kwh: 9.9},+	}+	mr := &mockReader{+		queryDailyEnergyFn: func(_ context.Context, serial, _, _ string) ([]dynamo.DailyEnergyItem, error) {+			return []dynamo.DailyEnergyItem{+				{SysSn: serial, Date: "2026-04-13", EInput: 21, BandImports: stored, BandsComputedAt: "x"},+				{SysSn: serial, Date: "2026-04-14", EInput: 20},+			}, nil+		},+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	req := makeRequest("GET", "/history", "Bearer "+testToken)+	req.QueryStringParameters = map[string]string{"days": "7"}+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	var hr HistoryResponse+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &hr))+	require.Len(t, hr.Days, 2)+	require.Len(t, hr.Days[0].BandImports, 4)+	assert.InDelta(t, 5.5, hr.Days[0].BandImports[1].Kwh, 1e-9)+	assert.Nil(t, hr.Days[1].BandImports, "a row without a captured split carries none")+}++func TestStatus_DoesNotCarryBandImports(t *testing.T) {+	// Q29: the Dashboard shows no costs, so /status has no use for the split.+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	mr := readerWithReadings(steadyImportReadings(now, 1000))+	mr.getDailyEnergyFn = func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+		return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 16}, nil+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), statusRequest())+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)+	assert.False(t, strings.Contains(resp.Body, "bandImports"),+		"/status must not carry the per-band split")+}++func TestDay_TodayBandImportsAbsentWhenNoPlan(t *testing.T) {+	// An unpriced day has no band geometry to report (AC 2.7).+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	mr := readerWithReadings(steadyImportReadings(now, 1000))+	mr.getDailyEnergyFn = func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+		return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 16}, nil+	}+	h := handlerWithPlans(mr)+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": "2026-04-15"}))+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)+	assert.NotContains(t, resp.Body, "bandImports")+}++func TestLiveBandImports_UnavailableWhenAStartedBandCannotIntegrate(t *testing.T) {+	// AC 3.6: a partially known split counts as unavailable, so the client+	// falls back rather than pricing some bands at zero.+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	midnight := startOfDaySydney(now)+	// Readings only from 12:00 onwards: the 00:00-01:00 band has started but+	// has no samples at all.+	var readings []dynamo.ReadingItem+	for ts := midnight.Add(12 * time.Hour); !ts.After(now); ts = ts.Add(time.Minute) {+		readings = append(readings, dynamo.ReadingItem{Timestamp: ts.Unix(), Pgrid: 1000})+	}++	_, ok := liveBandImports(readings, now, touPlan("p").Plan())+	assert.False(t, ok, "an elapsed band with no usable samples makes the split unavailable")+}
internal/api/compute_test.go Modified +32 / -116
diff --git a/internal/api/compute_test.go b/internal/api/compute_test.goindex 40875e8..5c74caa 100644--- a/internal/api/compute_test.go+++ b/internal/api/compute_test.go@@ -122,7 +122,7 @@ func TestLiveOffpeakDeltas(t *testing.T) {  	for name, tc := range tests { 		t.Run(name, func(t *testing.T) {-			got, ok := liveOffpeakDeltas(readings, tc.now, windowStart, windowEnd)+			got, ok := liveOffpeakDeltas(readings, tc.now, win(windowStart, windowEnd)) 			assert.Equal(t, tc.wantOK, ok) 			if !ok { 				return@@ -156,8 +156,8 @@ func TestLiveOffpeakDeltasDeterminism(t *testing.T) {  	now := opStart.Add(time.Hour) -	first, ok1 := liveOffpeakDeltas(readings, now, windowStart, windowEnd)-	second, ok2 := liveOffpeakDeltas(readings, now, windowStart, windowEnd)+	first, ok1 := liveOffpeakDeltas(readings, now, win(windowStart, windowEnd))+	second, ok2 := liveOffpeakDeltas(readings, now, win(windowStart, windowEnd))  	require.True(t, ok1) 	require.True(t, ok2)@@ -217,7 +217,7 @@ func TestLivePeakGridImport(t *testing.T) {  	for name, tc := range tests { 		t.Run(name, func(t *testing.T) {-			got, ok := livePeakGridImport(readings, tc.now, windowStart, windowEnd)+			got, ok := livePeakGridImport(readings, tc.now, win(windowStart, windowEnd)) 			assert.Equal(t, tc.wantOK, ok) 			if !ok { 				return@@ -228,18 +228,18 @@ func TestLivePeakGridImport(t *testing.T) { 	} } -// TestLivePeakGridImportGates covers the not-usable paths: an unparseable+// TestLivePeakGridImportGates covers the not-usable paths: a day with no free // window and a morning window with too few samples to integrate. func TestLivePeakGridImportGates(t *testing.T) { 	loc := sydneyTZ 	dayStart := time.Date(2026, 4, 15, 0, 0, 0, 0, loc) -	t.Run("unparseable window returns false", func(t *testing.T) {+	t.Run("no free window returns false", func(t *testing.T) { 		readings := []dynamo.ReadingItem{ 			{Timestamp: dayStart.Add(time.Hour).Unix(), Pgrid: 1000}, 			{Timestamp: dayStart.Add(time.Hour + 10*time.Second).Unix(), Pgrid: 1000}, 		}-		_, ok := livePeakGridImport(readings, dayStart.Add(2*time.Hour), "nope", "14:00")+		_, ok := livePeakGridImport(readings, dayStart.Add(2*time.Hour), nil) 		assert.False(t, ok) 	}) @@ -247,7 +247,7 @@ func TestLivePeakGridImportGates(t *testing.T) { 		readings := []dynamo.ReadingItem{ 			{Timestamp: dayStart.Add(time.Hour).Unix(), Pgrid: 1000}, 		}-		_, ok := livePeakGridImport(readings, dayStart.Add(2*time.Hour), "11:00", "14:00")+		_, ok := livePeakGridImport(readings, dayStart.Add(2*time.Hour), win("11:00", "14:00")) 		assert.False(t, ok) 	}) }@@ -283,7 +283,7 @@ func TestBuildOffpeakDispatch(t *testing.T) { 			StartECharge:    999.0, 			StartEDischarge: 999.0, 		}-		got := buildOffpeak(op, readings, now, "11:00", "14:00")+		got := buildOffpeak(op, readings, now, win("11:00", "14:00")) 		require.NotNil(t, got) 		assert.Equal(t, "11:00", got.WindowStart) 		assert.Equal(t, "14:00", got.WindowEnd)@@ -305,7 +305,7 @@ func TestBuildOffpeakDispatch(t *testing.T) { 		} 		// Readings would integrate to 1.8 if used — assert we see 2.5 instead, 		// proving the complete branch reads from the row.-		got := buildOffpeak(op, readings, now, "11:00", "14:00")+		got := buildOffpeak(op, readings, now, win("11:00", "14:00")) 		require.NotNil(t, got) 		require.NotNil(t, got.GridUsageKwh) 		assert.Equal(t, 2.5, *got.GridUsageKwh)@@ -314,7 +314,7 @@ func TestBuildOffpeakDispatch(t *testing.T) { 	})  	t.Run("nil item returns window only", func(t *testing.T) {-		got := buildOffpeak(nil, readings, now, "11:00", "14:00")+		got := buildOffpeak(nil, readings, now, win("11:00", "14:00")) 		require.NotNil(t, got) 		assert.Equal(t, "11:00", got.WindowStart) 		assert.Equal(t, "14:00", got.WindowEnd)@@ -324,7 +324,7 @@ func TestBuildOffpeakDispatch(t *testing.T) { 	t.Run("pending row before window returns no deltas (AC 4.3)", func(t *testing.T) { 		beforeWindow := time.Date(2026, 4, 15, 10, 0, 0, 0, loc) 		op := &dynamo.OffpeakItem{Status: dynamo.OffpeakStatusPending}-		got := buildOffpeak(op, readings, beforeWindow, "11:00", "14:00")+		got := buildOffpeak(op, readings, beforeWindow, win("11:00", "14:00")) 		require.NotNil(t, got) 		assert.Equal(t, "11:00", got.WindowStart) 		assert.Empty(t, got.Status, "no status before window opens")@@ -353,7 +353,7 @@ func TestOffpeakSplitDispatch(t *testing.T) { 			Status:      dynamo.OffpeakStatusPending, 			StartEInput: 999.0, // must not be read 		}-		imp, exp, hasSplit := offpeakSplit(op, readings, now, true, "11:00", "14:00")+		imp, exp, hasSplit := offpeakSplit(op, readings, now, true, win("11:00", "14:00")) 		require.True(t, hasSplit) 		assert.InDelta(t, 1.8, imp, 0.01) 		assert.InDelta(t, 0, exp, 0.01)@@ -366,7 +366,7 @@ func TestOffpeakSplitDispatch(t *testing.T) { 			GridExportKwh: 0.3, 		} 		// Past day (isToday=false) — still a pass-through.-		imp, exp, hasSplit := offpeakSplit(op, nil, now, false, "11:00", "14:00")+		imp, exp, hasSplit := offpeakSplit(op, nil, now, false, win("11:00", "14:00")) 		require.True(t, hasSplit) 		assert.Equal(t, 2.5, imp) 		assert.Equal(t, 0.3, exp)@@ -374,14 +374,14 @@ func TestOffpeakSplitDispatch(t *testing.T) {  	t.Run("pending past-date row has no split", func(t *testing.T) { 		op := dynamo.OffpeakItem{Status: dynamo.OffpeakStatusPending}-		_, _, hasSplit := offpeakSplit(op, readings, now, false, "11:00", "14:00")+		_, _, hasSplit := offpeakSplit(op, readings, now, false, win("11:00", "14:00")) 		assert.False(t, hasSplit, "pending past-date row indicates poller failure, no split") 	})  	t.Run("pending today row before offpeak-start returns no split (AC 4.3)", func(t *testing.T) { 		beforeWindow := time.Date(2026, 4, 15, 10, 0, 0, 0, loc) 		op := dynamo.OffpeakItem{Status: dynamo.OffpeakStatusPending}-		_, _, hasSplit := offpeakSplit(op, readings, beforeWindow, true, "11:00", "14:00")+		_, _, hasSplit := offpeakSplit(op, readings, beforeWindow, true, win("11:00", "14:00")) 		assert.False(t, hasSplit) 	}) }@@ -447,71 +447,6 @@ func TestComputeCutoffTime(t *testing.T) { 	} } -func TestNextOffpeakStart(t *testing.T) {-	const opStart = "11:00"-	const opEnd = "14:00"--	syd := func(h, m int) time.Time {-		return time.Date(2026, 4, 15, h, m, 0, 0, sydneyTZ)-	}--	tests := map[string]struct {-		now          time.Time-		offpeakStart string-		offpeakEnd   string-		wantValid    bool-		wantStart    time.Time-	}{-		"morning before window": {-			now:          syd(9, 0),-			offpeakStart: opStart, offpeakEnd: opEnd,-			wantValid: true,-			wantStart: syd(11, 0),-		},-		"exactly at window start": {-			now:          syd(11, 0),-			offpeakStart: opStart, offpeakEnd: opEnd,-			wantValid: true,-			wantStart: syd(11, 0),-		},-		"inside window": {-			now:          syd(12, 30),-			offpeakStart: opStart, offpeakEnd: opEnd,-			wantValid: true,-			wantStart: syd(11, 0),-		},-		"exactly at window end rolls to tomorrow": {-			now:          syd(14, 0),-			offpeakStart: opStart, offpeakEnd: opEnd,-			wantValid: true,-			wantStart: syd(11, 0).AddDate(0, 0, 1),-		},-		"after window same day": {-			now:          syd(18, 0),-			offpeakStart: opStart, offpeakEnd: opEnd,-			wantValid: true,-			wantStart: syd(11, 0).AddDate(0, 0, 1),-		},-		"invalid window returns false": {-			now:          syd(9, 0),-			offpeakStart: "bad", offpeakEnd: "also-bad",-			wantValid: false,-		},-	}--	for name, tc := range tests {-		t.Run(name, func(t *testing.T) {-			got, ok := nextOffpeakStart(tc.now, tc.offpeakStart, tc.offpeakEnd)-			assert.Equal(t, tc.wantValid, ok)-			if tc.wantValid {-				assert.True(t, got.Equal(tc.wantStart),-					"nextOffpeakStart(%s, %s, %s) = %s, want %s",-					tc.now, tc.offpeakStart, tc.offpeakEnd, got, tc.wantStart)-			}-		})-	}-}- func TestStartOfDaySydney(t *testing.T) { 	syd := func(y, m, d, h, mi int) time.Time { 		return time.Date(y, time.Month(m), d, h, mi, 0, 0, sydneyTZ)@@ -947,40 +882,21 @@ func TestWithinOffpeakWindow(t *testing.T) { 	}  	tests := map[string]struct {-		now          time.Time-		offpeakStart string-		offpeakEnd   string-		want         bool+		now    time.Time+		window *offpeakWindow+		want   bool 	}{-		"before window": {-			now: syd(10, 59), offpeakStart: "11:00", offpeakEnd: "14:00",-			want: false,-		},-		"at start": {-			now: syd(11, 0), offpeakStart: "11:00", offpeakEnd: "14:00",-			want: true,-		},-		"mid-window": {-			now: syd(12, 30), offpeakStart: "11:00", offpeakEnd: "14:00",-			want: true,-		},-		"at end (exclusive)": {-			now: syd(14, 0), offpeakStart: "11:00", offpeakEnd: "14:00",-			want: false,-		},-		"after window": {-			now: syd(14, 30), offpeakStart: "11:00", offpeakEnd: "14:00",-			want: false,-		},-		"unparseable strings": {-			now: syd(12, 0), offpeakStart: "x", offpeakEnd: "y",-			want: false,-		},+		"before window":      {now: syd(10, 59), window: win("11:00", "14:00"), want: false},+		"at start":           {now: syd(11, 0), window: win("11:00", "14:00"), want: true},+		"mid-window":         {now: syd(12, 30), window: win("11:00", "14:00"), want: true},+		"at end (exclusive)": {now: syd(14, 0), window: win("11:00", "14:00"), want: false},+		"after window":       {now: syd(14, 30), window: win("11:00", "14:00"), want: false},+		"no free window":     {now: syd(12, 0), window: nil, want: false}, 	}  	for name, tc := range tests { 		t.Run(name, func(t *testing.T) {-			got := withinOffpeakWindow(tc.now, tc.offpeakStart, tc.offpeakEnd)+			got := withinOffpeakWindow(tc.now, tc.window) 			assert.Equal(t, tc.want, got) 		}) 	}@@ -1207,7 +1123,7 @@ func TestProjectOffpeakEndSoc(t *testing.T) {  	for name, tc := range tests { 		t.Run(name, func(t *testing.T) {-			got := projectOffpeakEndSoc(tc.soc, tc.cap, tc.now, start, end)+			got := projectOffpeakEndSoc(tc.soc, tc.cap, tc.now, win(start, end)) 			if tc.want == nil { 				assert.Nil(t, got) 				return@@ -1252,7 +1168,7 @@ func TestPropertyProjectOffpeakEndSoc(t *testing.T) { 		f := func(socRaw, capRaw, fRaw float64) bool { 			soc := normSoc(socRaw) 			capKwh := normCap(capRaw)-			got := projectOffpeakEndSoc(soc, capKwh, nowFor(fRaw), start, end)+			got := projectOffpeakEndSoc(soc, capKwh, nowFor(fRaw), win(start, end)) 			if got == nil { 				return false // always in-window with positive capacity 			}@@ -1269,8 +1185,8 @@ func TestPropertyProjectOffpeakEndSoc(t *testing.T) { 			// Earlier now (more hours remaining) vs the same instant moved 			// later (fewer hours). More hours must not yield a lower SoC. 			fLate := f0 + (1-f0)/2 // strictly later than f0, still < 1-			early := projectOffpeakEndSoc(soc, capKwh, nowFor(f0), start, end)-			late := projectOffpeakEndSoc(soc, capKwh, nowFor(fLate), start, end)+			early := projectOffpeakEndSoc(soc, capKwh, nowFor(f0), win(start, end))+			late := projectOffpeakEndSoc(soc, capKwh, nowFor(fLate), win(start, end)) 			if early == nil || late == nil { 				return false 			}@@ -1287,8 +1203,8 @@ func TestPropertyProjectOffpeakEndSoc(t *testing.T) { 			// Same now and capacity; a higher starting SoC must not project a 			// lower end SoC. 			highSoc := lowSoc + (100-lowSoc)/2 // strictly greater, <= 100-			low := projectOffpeakEndSoc(lowSoc, capKwh, now, start, end)-			high := projectOffpeakEndSoc(highSoc, capKwh, now, start, end)+			low := projectOffpeakEndSoc(lowSoc, capKwh, now, win(start, end))+			high := projectOffpeakEndSoc(highSoc, capKwh, now, win(start, end)) 			if low == nil || high == nil { 				return false 			}
internal/api/compute.go Modified +156 / -57
diff --git a/internal/api/compute.go b/internal/api/compute.goindex cc7b875..779792e 100644--- a/internal/api/compute.go+++ b/internal/api/compute.go@@ -7,6 +7,7 @@ import (  	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" )  // sydneyTZ is the Australia/Sydney timezone used for all date-based operations.@@ -19,6 +20,57 @@ var sydneyTZ = func() *time.Location { 	return loc }() +// offpeakWindow is the free window of the plan pricing one date, expressed as+// Sydney-local minutes of day.+//+// Callers hold it as a *offpeakWindow, where nil means the date has no free+// window — either its plan carries no free band or no plan prices it at all.+// Every window-dependent value is then absent rather than defaulted (AC 4.4);+// the pointer is what makes that state impossible to confuse with a real+// window.+type offpeakWindow struct {+	startMin, endMin int+}++// startHHMM / endHHMM render the window in the "HH:MM" form the derivedstats+// helpers still parse.+func (w offpeakWindow) startHHMM() string { return plan.FormatBandTime(w.startMin) }+func (w offpeakWindow) endHHMM() string   { return plan.FormatBandTime(w.endMin) }++// resolveOffpeakWindow returns the free window of the plan pricing date+// (AC 4.1), or nil when there is none.+func resolveOffpeakWindow(plans []plan.Plan, date string) *offpeakWindow {+	startMin, endMin, ok := plan.FreeWindow(plans, date)+	if !ok {+		return nil+	}+	return &offpeakWindow{startMin: startMin, endMin: endMin}+}++// hhmmBounds renders a window for the derivedstats helpers that take "HH:MM"+// strings. A nil window yields two empty strings, which those helpers already+// treat as "no off-peak window" and degrade accordingly.+func hhmmBounds(w *offpeakWindow) (start, end string) {+	if w == nil {+		return "", ""+	}+	return w.startHHMM(), w.endHHMM()+}++// bounds resolves the window to absolute Sydney-local instants on the day+// containing local.+//+// Boundaries come from plan.SegmentBounds, the same wall-clock resolution the+// poller's capture and liveBandImports use. Adding elapsed minutes to midnight+// instead would put the window an hour off on the two DST-transition days a+// year, so the free-window edge and the band edges beside it would disagree+// (Data Consistency).+func (w offpeakWindow) bounds(local time.Time) (start, end time.Time) {+	seg := plan.Segment{Start: w.startHHMM(), End: w.endHHMM()}+	startUnix, endUnix := plan.SegmentBounds(seg, local, sydneyTZ)+	return time.Unix(startUnix, 0).In(sydneyTZ), time.Unix(endUnix, 0).In(sydneyTZ)+}+ // offpeakDeltas resolves the energy deltas for a complete off-peak record. // Pending records return (_, false); callers needing today's in-window value // live-integrate via liveOffpeakDeltas.@@ -47,27 +99,24 @@ type offpeakDeltaValues struct { 	GridExport       float64 } -// liveOffpeakDeltas integrates readings over [offpeakStart, min(now, offpeakEnd))-// for today's date in Sydney local time and returns the five energy deltas.+// liveOffpeakDeltas integrates readings over [window start, min(now, window+// end)) for today's date in Sydney local time and returns the five energy+// deltas. //-// offpeakStart and offpeakEnd are the raw "HH:MM" config values. Returns-// (_, false) when the window is unparseable, when now is at or before the-// window start (AC 4.3 — pre-window behaviour), or when the readings slice+// Returns (_, false) when the day has no free window, when now is at or before+// the window start (AC 4.3 — pre-window behaviour), or when the readings slice // does not contain enough usable samples to integrate (AC 1.6). // // Pure function: no state and no clock except the explicit now parameter. This // is the determinism contract that backs AC 4.4's monotonicity guarantee. func liveOffpeakDeltas(readings []dynamo.ReadingItem, now time.Time,-	offpeakStart, offpeakEnd string,+	window *offpeakWindow, ) (offpeakDeltaValues, bool) {-	startMin, endMin, parsed := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)-	if !parsed {+	if window == nil { 		return offpeakDeltaValues{}, false 	} 	local := now.In(sydneyTZ)-	dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, sydneyTZ)-	opStart := dayStart.Add(time.Duration(startMin) * time.Minute)-	opEnd := dayStart.Add(time.Duration(endMin) * time.Minute)+	opStart, opEnd := window.bounds(local)  	if !local.After(opStart) { 		return offpeakDeltaValues{}, false@@ -113,9 +162,8 @@ func liveOffpeakDeltas(readings []dynamo.ReadingItem, now time.Time, // It is the "peak so far today" complement of liveOffpeakDeltas, computed // directly from readings (independent of reconcileEnergy) so the off-peak // sampling artifact is never dumped onto the peak residual (T-1421).-// offpeakStart and offpeakEnd are the raw "HH:MM" config values. //-// Returns (_, false) when the window is unparseable or the morning window has+// Returns (_, false) when the day has no free window or the morning window has // too few usable samples to integrate — the same <2-point usability gate // liveOffpeakDeltas uses. The evening window is additive-when-usable: before // now passes opEnd, or when it is too sparse to integrate on its own, it@@ -126,16 +174,14 @@ func liveOffpeakDeltas(readings []dynamo.ReadingItem, now time.Time, // // Pure function: no state and no clock except the explicit now parameter. func livePeakGridImport(readings []dynamo.ReadingItem, now time.Time,-	offpeakStart, offpeakEnd string,+	window *offpeakWindow, ) (float64, bool) {-	startMin, endMin, parsed := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)-	if !parsed {+	if window == nil { 		return 0, false 	} 	local := now.In(sydneyTZ)-	dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, sydneyTZ)-	opStart := dayStart.Add(time.Duration(startMin) * time.Minute)-	opEnd := dayStart.Add(time.Duration(endMin) * time.Minute)+	dayStart := startOfDaySydney(local)+	opStart, opEnd := window.bounds(local) 	dayEnd := dayStart.AddDate(0, 0, 1)  	// Trim to today's readings so the result is identical regardless of whether@@ -185,6 +231,60 @@ func livePeakGridImport(readings []dynamo.ReadingItem, now time.Time, 	return peak, true } +// liveBandImports integrates max(pgrid, 0) over each rated segment of the plan+// pricing today, each clamped to now. It is the live counterpart of the+// poller's day-close band capture, and the single source both /day and+// /history use — the two screens cannot disagree about today's split because+// they compute it exactly once, here (AC 3.4).+//+// A segment the clock has not reached yet contributes 0: the day is not over,+// and that band has genuinely imported nothing so far. A segment that HAS+// started but cannot be integrated makes the whole split unavailable rather+// than partially known, because a partially known split is what AC 3.6 defines+// as unavailable — pricing the unknown bands at zero would understate the day.+//+// Boundaries come from plan.SegmentBounds, so band membership follows local+// wall-clock time across a DST transition (AC 3.8).+//+// Returns (_, false) when the plan has no rated segments or any started+// segment fails the integrator's usability gate.+func liveBandImports(readings []dynamo.ReadingItem, now time.Time, p plan.Plan) ([]BandImport, bool) {+	rated := plan.RatedSegments(p)+	if len(rated) == 0 {+		return nil, false+	}+	local := now.In(sydneyTZ)++	// Trim to today's readings so the result does not depend on whether the+	// caller's slice carries pre-midnight samples — the same cross-screen+	// identity argument livePeakGridImport makes. Readings are sorted+	// ascending (the DynamoDB sort-key guarantee).+	dayStartUnix := startOfDaySydney(local).Unix()+	if i := sort.Search(len(readings), func(i int) bool {+		return readings[i].Timestamp >= dayStartUnix+	}); i > 0 {+		readings = readings[i:]+	}++	nowUnix := local.Unix()+	out := make([]BandImport, 0, len(rated))+	for _, seg := range rated {+		startUnix, endUnix := plan.SegmentBounds(seg, local, sydneyTZ)+		entry := BandImport{Start: seg.Start, End: seg.End}+		if nowUnix > startUnix {+			end := min(endUnix, nowUnix)+			windowed := sliceWindow(readings, startUnix, end)+			deltas, ok := derivedstats.IntegrateOffpeakDeltas(toDerivedReadings(windowed), startUnix, end)+			if !ok {+				return nil, false+			}+			entry.Kwh = derivedstats.RoundEnergy(deltas.GridImportKwh)+		}+		out = append(out, entry)+	}+	return out, true+}+ // computeCutoffTime estimates when the battery will reach the cutoff percentage // using linear extrapolation. Returns nil if the battery is not discharging or // SOC is already at/below cutoff.@@ -223,25 +323,20 @@ const ( // window end using the idealised two-rate charge curve: offpeakChargeRateKW // while SoC < fastChargeMaxSoc, then offpeakTrickleRateKW up to 100%. //-// Returns nil when the window is unparseable, now is outside [start, end), or+// Returns nil when the day has no free window, now is outside [start, end), or // capacity is non-positive. The result is clamped to [soc, 100] and rounded to // 1 dp. The projection is a best-case figure independent of the live charge // power: it never reads Pbat or the simulated load, so AC 1.9 and AC 2.4 hold // by construction (Decision 4, Decision 6).-func projectOffpeakEndSoc(soc, capacityKwh float64, now time.Time, offpeakStart, offpeakEnd string) *float64 {-	if capacityKwh <= 0 || !withinOffpeakWindow(now, offpeakStart, offpeakEnd) {-		return nil-	}-	_, endMin, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)-	if !ok {+func projectOffpeakEndSoc(soc, capacityKwh float64, now time.Time, window *offpeakWindow) *float64 {+	if capacityKwh <= 0 || !withinOffpeakWindow(now, window) { 		return nil 	} -	// Window-end instant: today's Sydney-local midnight + endMin, same-	// construction as nextOffpeakStart. withinOffpeakWindow gates on-	// minute-of-day so now is always before this instant here; h is a-	// positive, seconds-precise duration absorbed by the [soc, 100] clamp.-	windowEnd := startOfDaySydney(now).Add(time.Duration(endMin) * time.Minute)+	// withinOffpeakWindow gates on minute-of-day, so now is always before the+	// window end here; h is a positive, seconds-precise duration absorbed by+	// the [soc, 100] clamp.+	_, windowEnd := window.bounds(now) 	h := windowEnd.Sub(now).Hours()  	// r converts a charge power (kW) to a SoC rate (percent per hour).@@ -303,17 +398,14 @@ func computeCantEmptyBeforeOffpeak(in cantEmptyInput) *bool { }  // withinOffpeakWindow reports whether now (in Sydney local time per the-// handler's invariant) falls inside the off-peak window [start, end).-// Parsing is delegated to derivedstats.ParseOffpeakWindow so this stays a-// single source of truth — unparseable inputs return false rather than-// raising an error (consistent with how cutoff-time suppression degrades).-func withinOffpeakWindow(now time.Time, offpeakStart, offpeakEnd string) bool {-	startMin, endMin, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)-	if !ok {+// handler's invariant) falls inside the free window [start, end). A day with+// no free window is never "within" one.+func withinOffpeakWindow(now time.Time, window *offpeakWindow) bool {+	if window == nil { 		return false 	} 	minuteOfDay := now.Hour()*60 + now.Minute()-	return minuteOfDay >= startMin && minuteOfDay < endMin+	return minuteOfDay >= window.startMin && minuteOfDay < window.endMin }  // computeRollingAverages returns the mean pload and pbat over the given readings.@@ -481,26 +573,33 @@ func reconcileEnergy(computed *TodayEnergy, stored *TodayEnergy) *TodayEnergy { 	} } -// nextOffpeakStart returns the absolute Sydney-local time of the next-// off-peak window start, used to suppress cutoff predictions that land at or-// after the next scheduled charging window. Today's start is returned-// whenever now is before today's end (including inside the window — during-// which any future cutoff is also >= start, so it is suppressed); tomorrow's-// start is returned once now has passed today's end. Returns (_, false) for-// an unparseable off-peak configuration.-func nextOffpeakStart(now time.Time, offpeakStart, offpeakEnd string) (time.Time, bool) {-	startMin, endMin, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)-	if !ok {-		return time.Time{}, false-	}+// nextOffpeakStart returns the absolute Sydney-local time of the next free+// window start, used to suppress cutoff predictions that land at or after the+// next scheduled charging window. Today's start is returned whenever now is+// before today's end (including inside the window — during which any future+// cutoff is also >= start, so it is suppressed); tomorrow's start is returned+// once now has passed today's end.+//+// Each candidate window comes from the plan pricing the day that window falls+// on (Q11/AC 4.2): on the eve of a plan switch the successor's window is the+// one the battery will actually charge in, so anchoring to today's plan would+// suppress against a boundary that no longer exists. Returns (_, false) when+// neither day has a free window.+func nextOffpeakStart(now time.Time, plans []plan.Plan) (time.Time, bool) { 	local := now.In(sydneyTZ)-	dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, sydneyTZ)-	todayStart := dayStart.Add(time.Duration(startMin) * time.Minute)-	todayEnd := dayStart.Add(time.Duration(endMin) * time.Minute)-	if !local.Before(todayEnd) {-		return todayStart.AddDate(0, 0, 1), true+	if window := resolveOffpeakWindow(plans, local.Format("2006-01-02")); window != nil {+		todayStart, todayEnd := window.bounds(local)+		if local.Before(todayEnd) {+			return todayStart, true+		}+	}+	tomorrow := local.AddDate(0, 0, 1)+	window := resolveOffpeakWindow(plans, tomorrow.Format("2006-01-02"))+	if window == nil {+		return time.Time{}, false 	}-	return todayStart, true+	start, _ := window.bounds(tomorrow)+	return start, true }  // startOfDaySydney returns 00:00 on now's Sydney-local date, used as the
internal/api/cross_handler_test.go Modified +2 / -2
diff --git a/internal/api/cross_handler_test.go b/internal/api/cross_handler_test.goindex 243ebe5..8dd1840 100644--- a/internal/api/cross_handler_test.go+++ b/internal/api/cross_handler_test.go@@ -34,7 +34,7 @@ func TestCrossHandlerEquivalence_PastDateDerivedStats(t *testing.T) { 	}  	now := fixedNow()-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	dayResp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -133,7 +133,7 @@ func TestCrossHandlerEquivalence_OldDateSummaryFields(t *testing.T) { 	}  	now := fixedNow()-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	dayResp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))
internal/api/day_derivedstats_test.go Modified +6 / -6
diff --git a/internal/api/day_derivedstats_test.go b/internal/api/day_derivedstats_test.goindex cb02f9f..581f518 100644--- a/internal/api/day_derivedstats_test.go+++ b/internal/api/day_derivedstats_test.go@@ -104,7 +104,7 @@ func TestHandleDay_PastDate_AllDerivedFieldsPresent(t *testing.T) { 		}, 	}} -	h := NewHandler(tr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(tr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return fixedNow() }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -151,7 +151,7 @@ func TestHandleDay_PastDate_OneFieldAbsent(t *testing.T) { 			return row, nil 		}, 	}}-	h := NewHandler(tr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(tr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return fixedNow() }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -179,7 +179,7 @@ func TestHandleDay_PastDate_AllDerivedFieldsAbsent(t *testing.T) { 			return row, nil 		}, 	}}-	h := NewHandler(tr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(tr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return fixedNow() }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -214,7 +214,7 @@ func TestHandleDay_PastDate_NoDerivedStats_FallsBackToDailyPower(t *testing.T) { 			}, nil 		}, 	}}-	h := NewHandler(tr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(tr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return fixedNow() }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -264,7 +264,7 @@ func TestHandleDay_Today_SolarKwh_OnDaylightBlocks(t *testing.T) { 		}, 	}} -	h := NewHandler(tr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(tr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -326,7 +326,7 @@ func TestHandleDay_Today_LiveCompute_Unchanged(t *testing.T) { 		}, 	}} -	h := NewHandler(tr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(tr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))
internal/api/day_offpeak_test.go Modified +2 / -2
diff --git a/internal/api/day_offpeak_test.go b/internal/api/day_offpeak_test.goindex bef3154..db03896 100644--- a/internal/api/day_offpeak_test.go+++ b/internal/api/day_offpeak_test.go@@ -97,7 +97,7 @@ func TestHandleDaySummaryOffpeakSplit(t *testing.T) { 				getOffpeakFn: tc.offpeakFn, 			} -			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(mr, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -188,7 +188,7 @@ func TestHandleDaySummaryOffpeakSplitDispatch(t *testing.T) { 				getOffpeakFn: tc.offpeakFn, 			} -			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(mr, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))
internal/api/day_test.go Modified +15 / -15
diff --git a/internal/api/day_test.go b/internal/api/day_test.goindex 330e332..eb06e33 100644--- a/internal/api/day_test.go+++ b/internal/api/day_test.go@@ -56,7 +56,7 @@ func TestHandleDayNormalCase(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -133,7 +133,7 @@ func TestHandleDayDailyUsageOvercast(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return fixedNow() }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -178,7 +178,7 @@ func TestHandleDayFallbackToDailyPower(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken)  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date})) 	require.NoError(t, err)@@ -245,7 +245,7 @@ func TestHandleDayOnlyDailyEnergySocLowIsNull(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken)  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": "2026-04-14"})) 	require.NoError(t, err)@@ -271,7 +271,7 @@ func TestHandleDayNoDataFromEitherSource(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken)  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": "2026-04-14"})) 	require.NoError(t, err)@@ -303,7 +303,7 @@ func TestHandleDayReadingsButNoDailyEnergy(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -353,7 +353,7 @@ func TestHandleDayPeakPeriods(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -380,7 +380,7 @@ func TestHandleDayDateValidation(t *testing.T) {  	for name, tc := range tests { 		t.Run(name, func(t *testing.T) {-			h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(&mockReader{}, nil, testSerial, testToken)  			resp, err := h.Handle(context.Background(), dayRequest(tc.params)) 			require.NoError(t, err)@@ -412,7 +412,7 @@ func TestHandleDaySocLowFromRawNotDownsampled(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -455,7 +455,7 @@ func TestHandleDayTodayReconcilesEnergy(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -497,7 +497,7 @@ func TestHandleDayPastDateDoesNotReconcile(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": pastDate}))@@ -521,7 +521,7 @@ func TestHandleDayBundlesNote(t *testing.T) { 				return &dynamo.NoteItem{Date: d, Text: "Quiet day", UpdatedAt: "2026-04-14T01:00:00Z"}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return fixedNow() }  		resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -537,7 +537,7 @@ func TestHandleDayBundlesNote(t *testing.T) { 		mr := &mockReader{ 			getNoteFn: func(_ context.Context, _, _ string) (*dynamo.NoteItem, error) { return nil, nil }, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return fixedNow() }  		resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -555,7 +555,7 @@ func TestHandleDayBundlesNote(t *testing.T) { 				return nil, errors.New("throttled") 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return fixedNow() }  		resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -597,7 +597,7 @@ func TestHandleDayDynamoDBError(t *testing.T) {  	for name, tc := range tests { 		t.Run(name, func(t *testing.T) {-			h := NewHandler(tc.mock, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(tc.mock, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return fixedNow() }  			resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": tc.date}))
internal/api/day.go Modified +28 / -4
diff --git a/internal/api/day.go b/internal/api/day.goindex 2cedec8..8455ea5 100644--- a/internal/api/day.go+++ b/internal/api/day.go@@ -9,6 +9,7 @@ import ( 	"github.com/ArjenSchwarz/flux/internal/alphaess" 	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" 	"github.com/aws/aws-lambda-go/events" 	"golang.org/x/sync/errgroup" )@@ -45,10 +46,19 @@ func (h *Handler) handleDay(ctx context.Context, req events.LambdaFunctionURLReq 		readings []dynamo.ReadingItem 		deItem   *dynamo.DailyEnergyItem 		opItem   *dynamo.OffpeakItem+		plans    []plan.Plan 	)  	g, gctx := errgroup.WithContext(ctx) +	// Plans are gated, unlike the supplementary off-peak query: a read failure+	// must fail the request rather than resolve as "no plan" (Q14).+	g.Go(func() error {+		rows, err := h.listPlans(gctx)+		plans = rows+		return err+	})+ 	if isToday { 		g.Go(func() error { 			items, err := h.reader.QueryReadings(gctx, h.serial, dayStart.Unix(), dayEnd.Unix()-1)@@ -85,6 +95,11 @@ func (h *Handler) handleDay(ctx context.Context, req events.LambdaFunctionURLReq 	} 	noteText := waitNote() +	// window is the free band of the plan pricing the requested date (AC 4.1),+	// nil when that plan has no free band or the date is unpriced.+	window := resolveOffpeakWindow(plans, date)+	windowStart, windowEnd := hhmmBounds(window)+ 	var points []TimeSeriesPoint 	var socLow float64 	var socLowTime int64@@ -98,8 +113,8 @@ func (h *Handler) handleDay(ctx context.Context, req events.LambdaFunctionURLReq 			drs := toDerivedReadings(readings) 			socLow, socLowTime, hasSocLow = derivedstats.MinSOC(drs) 			points = downsample(readings, date)-			peakPeriods = derivedstats.PeakPeriods(drs, h.offpeakStart, h.offpeakEnd)-			dailyUsage = derivedstats.Blocks(drs, h.offpeakStart, h.offpeakEnd, date, today, now)+			peakPeriods = derivedstats.PeakPeriods(drs, windowStart, windowEnd)+			dailyUsage = derivedstats.Blocks(drs, windowStart, windowEnd, date, today, now) 		} else { 			// Today with no readings: fall back to flux-daily-power for the 			// chart and socLow.@@ -197,9 +212,10 @@ func (h *Handler) handleDay(ctx context.Context, req events.LambdaFunctionURLReq 			summary.EDischarge = floatPtr(energy.EDischarge) 		} 		if opItem != nil {-			if imp, exp, hasSplit := offpeakSplit(*opItem, readings, now, isToday, h.offpeakStart, h.offpeakEnd); hasSplit {+			if imp, exp, hasSplit := offpeakSplit(*opItem, readings, now, isToday, window); hasSplit { 				summary.OffpeakGridImportKwh = floatPtr(imp) 				summary.OffpeakGridExportKwh = floatPtr(exp)+				summary.OffpeakSource = offpeakSourceFrom(*opItem, isToday, window) 			} 		} 		// Peak grid import: today is integrated live from readings (T-1420,@@ -208,12 +224,20 @@ func (h *Handler) handleDay(ctx context.Context, req events.LambdaFunctionURLReq 		// the stored server-computed value. Absent on either path falls through 		// to the iOS residual fallback (e.g. pre-30-day rows, Decision 4b). 		if isToday {-			if peak, ok := livePeakGridImport(readings, now, h.offpeakStart, h.offpeakEnd); ok {+			if peak, ok := livePeakGridImport(readings, now, window); ok { 				summary.PeakGridImportKwh = floatPtr(derivedstats.RoundEnergy(peak)) 			} 		} else if deItem != nil && deItem.PeakGridImportKwh != nil { 			summary.PeakGridImportKwh = floatPtr(*deItem.PeakGridImportKwh) 		}+		// Per-band import split, resolved by the same helper /history uses so+		// the two endpoints cannot report a different split for the same day+		// (AC 3.4). An absent daily-energy row simply carries no stored split.+		var storedBands []dynamo.BandImportAttr+		if deItem != nil {+			storedBands = deItem.BandImports+		}+		summary.BandImports = bandImportsFor(plans, date, isToday, readings, now, storedBands) 		resp.Summary = summary 	} 
internal/api/devices_test.go Modified +1 / -1
diff --git a/internal/api/devices_test.go b/internal/api/devices_test.goindex 3ce25ea..3f48dfb 100644--- a/internal/api/devices_test.go+++ b/internal/api/devices_test.go@@ -54,7 +54,7 @@ func (s *fakeDeviceStore) PutDeviceConditional(_ context.Context, item dynamo.De }  func newDeviceTestHandler(store *fakeDeviceStore) *Handler {-	h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(&mockReader{}, nil, testSerial, testToken) 	h.devices = store 	return h }
internal/api/dst_window_test.go Added +80 / -0
diff --git a/internal/api/dst_window_test.go b/internal/api/dst_window_test.gonew file mode 100644index 0000000..ab73598--- /dev/null+++ b/internal/api/dst_window_test.go@@ -0,0 +1,80 @@+package api++import (+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/plan"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// This file pins the API's live window path to wall-clock time across Sydney's+// two DST transitions.+//+// It exists because the original implementation resolved the window as+// "midnight plus N minutes", which is an hour off on those two days, while+// liveBandImports in the same response used plan.SegmentBounds and was correct.+// Nothing caught it: the DST coverage elsewhere asserted only the UTC offset,+// which is right either way. These tests assert the wall-clock hour instead.++// sydneyDSTDays are the two days a year on which elapsed-minute arithmetic and+// wall-clock resolution disagree.+var sydneyDSTDays = map[string]string{+	"spring forward (23h day, 02:00 -> 03:00)": "2026-10-04",+	"fall back (25h day, 03:00 -> 02:00)":      "2026-04-05",+}++func TestOffpeakWindowBounds_FollowsWallClockAcrossDST(t *testing.T) {+	// AC 3.8: band membership follows local wall-clock time. A window declared+	// as 11:00-14:00 must start at 11:00 and end at 14:00 on every day of the+	// year, including the two whose midnight-to-window elapsed time is not+	// 11 hours.+	for name, date := range sydneyDSTDays {+		t.Run(name, func(t *testing.T) {+			day, err := time.ParseInLocation("2006-01-02", date, sydneyTZ)+			require.NoError(t, err)++			start, end := win("11:00", "14:00").bounds(day)++			assert.Equal(t, 11, start.In(sydneyTZ).Hour(), "window start wall-clock hour")+			assert.Equal(t, 0, start.In(sydneyTZ).Minute())+			assert.Equal(t, 14, end.In(sydneyTZ).Hour(), "window end wall-clock hour")+			assert.Equal(t, 0, end.In(sydneyTZ).Minute())+			assert.Equal(t, date, start.In(sydneyTZ).Format("2006-01-02"))+		})+	}+}++func TestOffpeakWindowBounds_AgreeWithRatedSegmentBounds(t *testing.T) {+	// Data Consistency: /day and /history compute today's peak import from+	// offpeakWindow.bounds and today's band split from plan.SegmentBounds, and+	// serve both in one payload. If the two resolve the same boundary to+	// different instants, the peak figure stops being the sum of the rated+	// bands. The free window is exactly the gap between the rated segments, so+	// the boundaries must coincide instant for instant.+	p := plan.Plan{+		ID:          "p",+		StartDate:   "2026-01-01",+		DefaultRate: 0.30,+		Windows:     []plan.Window{{Start: "11:00", End: "14:00", Free: true}},+	}+	rated := plan.RatedSegments(p)+	require.Len(t, rated, 2, "a single midday free band leaves a rated segment either side")++	for name, date := range sydneyDSTDays {+		t.Run(name, func(t *testing.T) {+			day, err := time.ParseInLocation("2006-01-02", date, sydneyTZ)+			require.NoError(t, err)++			windowStart, windowEnd := win("11:00", "14:00").bounds(day)+			_, morningEnd := plan.SegmentBounds(rated[0], day, sydneyTZ)+			eveningStart, _ := plan.SegmentBounds(rated[1], day, sydneyTZ)++			assert.Equal(t, morningEnd, windowStart.Unix(),+				"free-window start must be the instant the morning rated segment ends")+			assert.Equal(t, eveningStart, windowEnd.Unix(),+				"free-window end must be the instant the evening rated segment starts")+		})+	}+}
internal/api/handler_test.go Modified +13 / -1
diff --git a/internal/api/handler_test.go b/internal/api/handler_test.goindex 635fa81..14fe235 100644--- a/internal/api/handler_test.go+++ b/internal/api/handler_test.go@@ -92,7 +92,19 @@ const testSerial = "AB1234"  // newTestHandler creates a Handler with a mock reader and test credentials. func newTestHandler() *Handler {-	return NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	return newTestHandlerFor(&mockReader{}, nil, testSerial, testToken)+}++// newTestHandlerFor builds a Handler over the given reader and note writer,+// wired to a pricing store holding the migrated shape of the plan these tests+// were written against: free 11:00–14:00, open-ended, one flat rate. The+// window now comes from the plan, so tests that assert on window-dependent+// values need one; tests about the window itself supply their own plans via+// handlerWithPlans.+func newTestHandlerFor(reader dynamo.Reader, notes NoteWriter, serial, apiToken string) *Handler {+	h := NewHandler(reader, notes, serial, apiToken)+	h.SetPricingStore(storeWithPlans(planRow("legacy-plan", "2000-01-01", nil, freeBand("11:00", "14:00"))))+	return h }  // makeRequest builds a LambdaFunctionURLRequest with the given method, path, and optional auth header.
internal/api/handler.go Modified +41 / -20
diff --git a/internal/api/handler.go b/internal/api/handler.goindex ebeb910..299d808 100644--- a/internal/api/handler.go+++ b/internal/api/handler.go@@ -3,12 +3,15 @@ package api import ( 	"context" 	"encoding/json"+	"errors"+	"fmt" 	"log/slog" 	"net/http" 	"net/http/httptest" 	"time"  	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" 	"github.com/aws/aws-lambda-go/events" ) @@ -21,17 +24,15 @@ type NoteWriter interface {  // Handler processes Lambda Function URL requests with auth and routing. type Handler struct {-	reader       dynamo.Reader-	notes        NoteWriter-	devices      DeviceStore-	rules        SocRuleStore-	fireState    FireStateCleaner-	pricing      PricingStore-	presets      SimulationPresetStore-	serial       string-	apiToken     string-	offpeakStart string-	offpeakEnd   string+	reader    dynamo.Reader+	notes     NoteWriter+	devices   DeviceStore+	rules     SocRuleStore+	fireState FireStateCleaner+	pricing   PricingStore+	presets   SimulationPresetStore+	serial    string+	apiToken  string 	// nowFunc returns the current time. Defaults to time.Now. 	// Exposed for testing to ensure consistent time capture per request. 	nowFunc func() time.Time@@ -45,21 +46,41 @@ type Handler struct {  // NewHandler creates a Handler with all dependencies injected. Pass a nil // notes writer in tests that don't exercise the write endpoint.-func NewHandler(reader dynamo.Reader, notes NoteWriter, serial, apiToken, offpeakStart, offpeakEnd string) *Handler {+//+// The pricing store is wired separately via SetPricingStore, but it is not+// optional: /status, /day, and /history all derive the off-peak window from+// the plans it holds.+func NewHandler(reader dynamo.Reader, notes NoteWriter, serial, apiToken string) *Handler { 	h := &Handler{-		reader:       reader,-		notes:        notes,-		serial:       serial,-		apiToken:     apiToken,-		offpeakStart: offpeakStart,-		offpeakEnd:   offpeakEnd,-		nowFunc:      time.Now,-		idFunc:       defaultIDFunc,+		reader:   reader,+		notes:    notes,+		serial:   serial,+		apiToken: apiToken,+		nowFunc:  time.Now,+		idFunc:   defaultIDFunc, 	} 	h.mux = h.buildMux() 	return h } +// listPlans fetches every pricing plan as the domain type the window helpers+// take.+//+// A read failure is returned rather than swallowed: resolving it as "no plan"+// would strip a priced day's window, off-peak split, and cost data with no+// signal that anything went wrong (Q14). The read endpoints turn the error+// into a 500, same as any other store failure.+func (h *Handler) listPlans(ctx context.Context) ([]plan.Plan, error) {+	if h.pricing == nil {+		return nil, errors.New("pricing store not configured")+	}+	rows, err := h.pricing.ListPricing(ctx)+	if err != nil {+		return nil, fmt.Errorf("list pricing: %w", err)+	}+	return dynamo.PlansFromItems(rows), nil+}+ // SetNow overrides the clock used by request handlers. Intended for the // integration test, which lives in another package and cannot reach the // unexported nowFunc field directly. Safe to call before Handle.
internal/api/history_bench_test.go Modified +1 / -1
diff --git a/internal/api/history_bench_test.go b/internal/api/history_bench_test.goindex 26f4b15..9aee1f0 100644--- a/internal/api/history_bench_test.go+++ b/internal/api/history_bench_test.go@@ -47,7 +47,7 @@ func BenchmarkHandleHistory_30Days(b *testing.B) { 			return rows, nil 		}, 	}-	h := NewHandler(mr, nil, "TEST", "tok", "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, "TEST", "tok") 	h.nowFunc = func() time.Time { return now } 	req := historyRequest(map[string]string{"days": "30"}) 	req.Headers["authorization"] = "Bearer tok"
internal/api/history_derivedstats_test.go Modified +5 / -5
diff --git a/internal/api/history_derivedstats_test.go b/internal/api/history_derivedstats_test.goindex 4f9c7d4..174c874 100644--- a/internal/api/history_derivedstats_test.go+++ b/internal/api/history_derivedstats_test.go@@ -96,7 +96,7 @@ func TestHandleHistory_AllPastRowsHaveDerivedStats(t *testing.T) { 			return rows, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "7"}))@@ -129,7 +129,7 @@ func TestHandleHistory_OldestDayLacksDerivedFields(t *testing.T) { 			return []dynamo.DailyEnergyItem{rowOld, rowMid, rowRecent}, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "7"}))@@ -180,7 +180,7 @@ func TestHandleHistory_TodayLiveCompute(t *testing.T) { 			return todayReadings, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "7"}))@@ -223,7 +223,7 @@ func TestHandleHistory_TodayReadingsQueryFailure_AC4_9(t *testing.T) { 			return nil, errors.New("throttled") 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "7"}))@@ -293,7 +293,7 @@ func TestHandleHistory_TodayLiveCompute_TrimsPreMidnight(t *testing.T) { 			return allReadings, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "7"}))
internal/api/history_test.go Modified +16 / -16
diff --git a/internal/api/history_test.go b/internal/api/history_test.goindex bf6b5b1..5e26a3f 100644--- a/internal/api/history_test.go+++ b/internal/api/history_test.go@@ -48,7 +48,7 @@ func TestHandleHistoryDefaultDays(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -98,7 +98,7 @@ func TestHandleHistoryDaysValidation(t *testing.T) { 				}, 			} -			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(mr, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			var params map[string]string@@ -127,7 +127,7 @@ func TestHandleHistoryNoData(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -151,7 +151,7 @@ func TestHandleHistoryAscendingOrder(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -174,7 +174,7 @@ func TestHandleHistoryEnergyRounding(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -216,7 +216,7 @@ func TestHandleHistoryReconcilesTodaysRow(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -296,7 +296,7 @@ func TestHandleHistoryOffpeakSplit(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -336,7 +336,7 @@ func TestHandleHistoryDynamoDBError(t *testing.T) { 		}, 	} 	now := fixedNow()-	h := NewHandler(mock, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mock, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -375,7 +375,7 @@ func TestHandleHistoryBundlesNotes(t *testing.T) { 				}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -401,7 +401,7 @@ func TestHandleHistoryBundlesNotes(t *testing.T) { 				return []dynamo.NoteItem{}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -427,7 +427,7 @@ func TestHandleHistoryBundlesNotes(t *testing.T) { 				return nil, errors.New("throttled") 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), historyRequest(nil))@@ -542,7 +542,7 @@ func TestHandleHistoryRangeParamMatrix(t *testing.T) { 				}, 			} -			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(mr, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			resp, err := h.Handle(context.Background(), historyRequest(tc.params))@@ -600,7 +600,7 @@ func TestHandleHistoryRangeSkipsLiveCompute(t *testing.T) { 		}, 	}} -	h := NewHandler(tr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(tr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{@@ -649,7 +649,7 @@ func TestHandleHistoryRangePredatesData(t *testing.T) { 				return []dynamo.DailyEnergyItem{}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), historyRequest(map[string]string{@@ -670,7 +670,7 @@ func TestHandleHistoryRangePredatesData(t *testing.T) { 				}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), historyRequest(map[string]string{@@ -704,7 +704,7 @@ func TestHandleHistoryOffpeakSoftFailure(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(nil))
internal/api/history.go Modified +28 / -9
diff --git a/internal/api/history.go b/internal/api/history.goindex c19ef18..5858d9d 100644--- a/internal/api/history.go+++ b/internal/api/history.go@@ -8,6 +8,7 @@ import (  	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" 	"github.com/aws/aws-lambda-go/events" 	"golang.org/x/sync/errgroup" )@@ -87,9 +88,18 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 	var ( 		items        []dynamo.DailyEnergyItem 		offpeakItems []dynamo.OffpeakItem+		plans        []plan.Plan 	)  	g, gctx := errgroup.WithContext(ctx)+	// One plan fetch for the whole range; per-day windows are resolved from it+	// below. Gated, unlike the supplementary off-peak query: a read failure+	// must fail the request rather than resolve as "no plan" (Q14).+	g.Go(func() error {+		rows, err := h.listPlans(gctx)+		plans = rows+		return err+	}) 	g.Go(func() error { 		result, err := h.reader.QueryDailyEnergy(gctx, h.serial, startDate, endDate) 		items = result@@ -202,11 +212,15 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 			ECharge:    energy.ECharge, 			EDischarge: energy.EDischarge, 		}+		// Each day takes the free window of the plan pricing that day, so a+		// range spanning a switch date attributes each side correctly.+		window := resolveOffpeakWindow(plans, item.Date) 		if op, ok := offpeakByDate[item.Date]; ok {-			imp, exp, hasSplit := offpeakSplit(op, todayReadings, now, isItemToday, h.offpeakStart, h.offpeakEnd)+			imp, exp, hasSplit := offpeakSplit(op, todayReadings, now, isItemToday, window) 			if hasSplit { 				day.OffpeakGridImportKwh = floatPtr(imp) 				day.OffpeakGridExportKwh = floatPtr(exp)+				day.OffpeakSource = offpeakSourceFrom(op, isItemToday, window) 			} 		} 		// Peak grid import: today is integrated live from readings (T-1420,@@ -215,12 +229,16 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 		// the stored server-computed value; absent on either path falls through 		// to the iOS residual fallback (e.g. pre-30-day rows, Decision 4b). 		if isItemToday {-			if peak, ok := livePeakGridImport(todayReadings, now, h.offpeakStart, h.offpeakEnd); ok {+			if peak, ok := livePeakGridImport(todayReadings, now, window); ok { 				day.PeakGridImportKwh = floatPtr(derivedstats.RoundEnergy(peak)) 			} 		} else if item.PeakGridImportKwh != nil { 			day.PeakGridImportKwh = floatPtr(*item.PeakGridImportKwh) 		}+		// Per-band import split, resolved by the same helper /day uses so the+		// two endpoints cannot report a different split for the same day+		// (AC 3.4).+		day.BandImports = bandImportsFor(plans, item.Date, isItemToday, todayReadings, now, item.BandImports) 		if note, ok := notesByDate[item.Date]; ok { 			n := note 			day.Note = &n@@ -242,8 +260,9 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 			if todayDerivedReadings == nil { 				todayDerivedReadings = toDerivedReadings(todayReadings) 			}-			day.DailyUsage = derivedstats.Blocks(todayDerivedReadings, h.offpeakStart, h.offpeakEnd, today, today, now)-			day.PeakPeriods = derivedstats.PeakPeriods(todayDerivedReadings, h.offpeakStart, h.offpeakEnd)+			windowStart, windowEnd := hhmmBounds(window)+			day.DailyUsage = derivedstats.Blocks(todayDerivedReadings, windowStart, windowEnd, today, today, now)+			day.PeakPeriods = derivedstats.PeakPeriods(todayDerivedReadings, windowStart, windowEnd) 			if soc, ts, found := derivedstats.MinSOC(todayDerivedReadings); found { 				slv := soc 				day.SocLow = &slv@@ -265,12 +284,12 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR // // Complete records pass through the finalised deltas. A pending record on // today's date live-integrates from the readings slice over-// [offpeak-start, min(now, offpeak-end)). Pending records on past dates+// [window start, min(now, window end)). Pending records on past dates // indicate a poller failure and are reported as missing rather than zero.-// Returns hasSplit=false when the data is not usable (sparse readings or-// pre-window now).+// Returns hasSplit=false when the data is not usable (sparse readings,+// pre-window now, or a day with no free window). func offpeakSplit(op dynamo.OffpeakItem, readings []dynamo.ReadingItem, now time.Time,-	isToday bool, offpeakStart, offpeakEnd string,+	isToday bool, window *offpeakWindow, ) (imp, exp float64, hasSplit bool) { 	if op.Status == dynamo.OffpeakStatusComplete { 		deltas, ok := offpeakDeltas(op)@@ -282,7 +301,7 @@ func offpeakSplit(op dynamo.OffpeakItem, readings []dynamo.ReadingItem, now time 	if op.Status != dynamo.OffpeakStatusPending || !isToday { 		return 0, 0, false 	}-	deltas, ok := liveOffpeakDeltas(readings, now, offpeakStart, offpeakEnd)+	deltas, ok := liveOffpeakDeltas(readings, now, window) 	if !ok { 		return 0, 0, false 	}
internal/api/note_test.go Modified +2 / -2
diff --git a/internal/api/note_test.go b/internal/api/note_test.goindex 13c5634..3af32d3 100644--- a/internal/api/note_test.go+++ b/internal/api/note_test.go@@ -44,7 +44,7 @@ func (m *mockNoteWriter) DeleteNote(ctx context.Context, serial, date string) er // newNoteTestHandler returns a handler wired to the supplied notes writer and // a nowFunc pinned to 2026-04-15 10:00 Sydney so "today" is 2026-04-15. func newNoteTestHandler(notes NoteWriter) *Handler {-	h := NewHandler(&mockReader{}, notes, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(&mockReader{}, notes, testSerial, testToken) 	h.nowFunc = func() time.Time { return time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ) } 	return h }@@ -354,7 +354,7 @@ func TestHandleNote_DynamoErrorReturns500(t *testing.T) { func TestHandleNote_NilWriterReturns500NotPanic(t *testing.T) { 	// A misconfigured Lambda (e.g. TABLE_NOTES env var missing) wires a nil 	// writer. The handler must return 500 cleanly rather than nil-panic.-	h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(&mockReader{}, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return time.Date(2026, 4, 15, 10, 0, 0, 0, sydneyTZ) }  	req := noteRequest("PUT", map[string]string{"content-type": "application/json"}, `{"date":"2026-04-15","text":"hi"}`, false)
internal/api/offpeak_live_perf_test.go Modified +3 / -3
diff --git a/internal/api/offpeak_live_perf_test.go b/internal/api/offpeak_live_perf_test.goindex 6fac548..fab68e7 100644--- a/internal/api/offpeak_live_perf_test.go+++ b/internal/api/offpeak_live_perf_test.go@@ -2,7 +2,7 @@ package api  import ( 	"context"-	"sort"+	"slices" 	"testing" 	"time" @@ -62,7 +62,7 @@ func TestHandleStatus_LiveOffpeak_P95Under500ms(t *testing.T) { 			return &dynamo.OffpeakItem{Status: dynamo.OffpeakStatusPending}, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	// Warm-up — first call pays one-off costs (sync.Once init, allocator@@ -99,7 +99,7 @@ func TestHandleStatus_LiveOffpeak_P95Under500ms(t *testing.T) { 			"live path must populate gridUsageKwh from the readings integration") 	} -	sort.Slice(timings, func(i, j int) bool { return timings[i] < timings[j] })+	slices.Sort(timings) 	p50 := timings[samples/2] 	p95 := timings[(samples*95)/100] 	p99 := timings[(samples*99)/100]
internal/api/offpeak_source_test.go Added +177 / -0
diff --git a/internal/api/offpeak_source_test.go b/internal/api/offpeak_source_test.gonew file mode 100644index 0000000..799862f--- /dev/null+++ b/internal/api/offpeak_source_test.go@@ -0,0 +1,177 @@+package api++import (+	"context"+	"encoding/json"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// The client's banded cost tier needs to know which window a day's off-peak+// import was integrated under and whether that integration was a real+// measurement — otherwise a day priced by the new plan can never resolve past+// the fallback tier. These tests pin that provenance onto the /day and+// /history wire.++func TestDay_ServesTheOffpeakRowsGeometryAndProvenance(t *testing.T) {+	now := time.Date(2026, 8, 20, 10, 0, 0, 0, sydneyTZ)+	const date = "2026-08-15"+	opRow := dynamo.OffpeakItem{+		SysSn: testSerial, Date: date, Status: dynamo.OffpeakStatusComplete,+		GridUsageKwh: 3.1, GridExportKwh: 0.9,+		WindowStart: "10:00", WindowEnd: "15:00",+		IntegratedAt: "2026-08-16T05:00:00Z", IntegrationSampleCount: 1500,+	}+	mr := &mockReader{+		getDailyEnergyFn: func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+			return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 21}, nil+		},+		getOffpeakFn: func(_ context.Context, _, _ string) (*dynamo.OffpeakItem, error) {+			return &opRow, nil+		},+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	var dr DayDetailResponse+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &dr))+	require.NotNil(t, dr.Summary)+	assert.Equal(t, "10:00", dr.Summary.OffpeakWindowStart)+	assert.Equal(t, "15:00", dr.Summary.OffpeakWindowEnd)+	assert.Equal(t, "2026-08-16T05:00:00Z", dr.Summary.OffpeakIntegratedAt)+	require.NotNil(t, dr.Summary.OffpeakSampleCount)+	assert.Equal(t, 1500, *dr.Summary.OffpeakSampleCount)+}++func TestDay_PreFeatureRowReportsTheOnlyWindowItCanHaveHad(t *testing.T) {+	// A row written before the geometry snapshot existed carries no window,+	// and 11:00-14:00 is the only one it can have been integrated under — so+	// the wire states it rather than leaving the client to guess.+	now := time.Date(2026, 8, 20, 10, 0, 0, 0, sydneyTZ)+	const date = "2026-03-01"+	opRow := dynamo.OffpeakItem{+		SysSn: testSerial, Date: date, Status: dynamo.OffpeakStatusComplete,+		GridUsageKwh: 3.1, GridExportKwh: 0.9,+	}+	mr := &mockReader{+		getDailyEnergyFn: func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+			return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 21}, nil+		},+		getOffpeakFn: func(_ context.Context, _, _ string) (*dynamo.OffpeakItem, error) {+			return &opRow, nil+		},+	}+	h := handlerWithPlans(mr, planRow("p", "2000-01-01", nil, freeBand("11:00", "14:00")))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+	require.NoError(t, err)++	var dr DayDetailResponse+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &dr))+	require.NotNil(t, dr.Summary)+	assert.Equal(t, "11:00", dr.Summary.OffpeakWindowStart)+	assert.Equal(t, "14:00", dr.Summary.OffpeakWindowEnd)+	assert.Empty(t, dr.Summary.OffpeakIntegratedAt)+}++func TestDay_ASparseCompleteRowIsReportedAsSuch(t *testing.T) {+	// integratedAt set with no samples is a zero-delta artifact; the client+	// must be able to tell it apart from a measured zero.+	now := time.Date(2026, 8, 20, 10, 0, 0, 0, sydneyTZ)+	const date = "2026-08-15"+	opRow := dynamo.OffpeakItem{+		SysSn: testSerial, Date: date, Status: dynamo.OffpeakStatusComplete,+		WindowStart: "10:00", WindowEnd: "15:00",+		IntegratedAt: "2026-08-16T05:00:00Z", IntegrationSampleCount: 0,+	}+	mr := &mockReader{+		getDailyEnergyFn: func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+			return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 21}, nil+		},+		getOffpeakFn: func(_ context.Context, _, _ string) (*dynamo.OffpeakItem, error) {+			return &opRow, nil+		},+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+	require.NoError(t, err)++	var dr DayDetailResponse+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &dr))+	require.NotNil(t, dr.Summary)+	require.NotNil(t, dr.Summary.OffpeakSampleCount)+	assert.Equal(t, 0, *dr.Summary.OffpeakSampleCount)+	assert.Equal(t, "2026-08-16T05:00:00Z", dr.Summary.OffpeakIntegratedAt)+}++func TestDayAndHistoryAgreeOnTheOffpeakSource(t *testing.T) {+	now := time.Date(2026, 8, 20, 10, 0, 0, 0, sydneyTZ)+	const date = "2026-08-15"+	row := &dynamo.DailyEnergyItem{SysSn: testSerial, Date: date, EInput: 21}+	opRow := dynamo.OffpeakItem{+		SysSn: testSerial, Date: date, Status: dynamo.OffpeakStatusComplete,+		GridUsageKwh: 3.1, GridExportKwh: 0.9,+		WindowStart: "10:00", WindowEnd: "15:00",+		IntegratedAt: "2026-08-16T05:00:00Z", IntegrationSampleCount: 1500,+	}+	mr := &mockReader{+		getDailyEnergyFn: func(_ context.Context, _, _ string) (*dynamo.DailyEnergyItem, error) {+			return row, nil+		},+		getOffpeakFn: func(_ context.Context, _, _ string) (*dynamo.OffpeakItem, error) {+			return &opRow, nil+		},+		queryDailyEnergyFn: func(_ context.Context, _, _, _ string) ([]dynamo.DailyEnergyItem, error) {+			return []dynamo.DailyEnergyItem{*row}, nil+		},+		queryOffpeakFn: func(_ context.Context, _, _, _ string) ([]dynamo.OffpeakItem, error) {+			return []dynamo.OffpeakItem{opRow}, nil+		},+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	dayResp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+	require.NoError(t, err)+	var dr DayDetailResponse+	require.NoError(t, json.Unmarshal([]byte(dayResp.Body), &dr))++	historyResp, err := h.Handle(context.Background(), historyRequest(map[string]string{+		"start": date, "end": date,+	}))+	require.NoError(t, err)+	var hr HistoryResponse+	require.NoError(t, json.Unmarshal([]byte(historyResp.Body), &hr))++	require.NotNil(t, dr.Summary)+	require.Len(t, hr.Days, 1)+	assert.Equal(t, dr.Summary.OffpeakSource, hr.Days[0].OffpeakSource,+		"a value on two screens must come from one source")+}++func TestDay_NoOffpeakSplitCarriesNoSource(t *testing.T) {+	now := time.Date(2026, 8, 20, 10, 0, 0, 0, sydneyTZ)+	mr := &mockReader{+		getDailyEnergyFn: func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+			return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 21}, nil+		},+	}+	h := handlerWithPlans(mr, touPlan("p"))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": "2026-08-15"}))+	require.NoError(t, err)+	assert.NotContains(t, resp.Body, "offpeakWindowStart", "absent, not an empty string")+	assert.NotContains(t, resp.Body, "offpeakSampleCount")+}
internal/api/peak_grid_import_test.go Modified +10 / -10
diff --git a/internal/api/peak_grid_import_test.go b/internal/api/peak_grid_import_test.goindex f9c272a..66f1d27 100644--- a/internal/api/peak_grid_import_test.go+++ b/internal/api/peak_grid_import_test.go@@ -40,7 +40,7 @@ func TestHandleDayPeakGridImport(t *testing.T) { 				}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = fixedNow  		resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -60,7 +60,7 @@ func TestHandleDayPeakGridImport(t *testing.T) { 				return &dynamo.DailyEnergyItem{Date: date, EInput: 4.2}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = fixedNow  		resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -88,7 +88,7 @@ func TestHandleHistoryPeakGridImport(t *testing.T) { 			}, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "30"}))@@ -126,7 +126,7 @@ func TestHandleDayTodayLivePeakGridImport(t *testing.T) { 	readings := uniformTodayReadings(dayStart, now, 3600) // [00:00, 10:00].  	// Expected = the same live integration the handler performs, rounded.-	rawPeak, ok := livePeakGridImport(readings, now, "11:00", "14:00")+	rawPeak, ok := livePeakGridImport(readings, now, win("11:00", "14:00")) 	require.True(t, ok) 	wantPeak := derivedstats.RoundEnergy(rawPeak) 	require.Greater(t, wantPeak, 0.0)@@ -145,7 +145,7 @@ func TestHandleDayTodayLivePeakGridImport(t *testing.T) { 			return nil, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))@@ -169,7 +169,7 @@ func TestHandleHistoryTodayLivePeakGridImport(t *testing.T) { 	dayStart := time.Date(2026, 4, 15, 0, 0, 0, 0, sydneyTZ) 	readings := uniformTodayReadings(dayStart, now, 3600) -	rawPeak, ok := livePeakGridImport(readings, now, "11:00", "14:00")+	rawPeak, ok := livePeakGridImport(readings, now, win("11:00", "14:00")) 	require.True(t, ok) 	wantPeak := derivedstats.RoundEnergy(rawPeak) @@ -185,7 +185,7 @@ func TestHandleHistoryTodayLivePeakGridImport(t *testing.T) { 			return nil, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "7"}))@@ -238,7 +238,7 @@ func TestTodayPeakGridImportConsistentAcrossEndpoints(t *testing.T) { 				return []dynamo.DailyEnergyItem{{Date: today, EInput: 3.0}}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now } 		return h 	}@@ -280,7 +280,7 @@ func TestHandleStatusLivePeakGridImport(t *testing.T) { 	dayStart := time.Date(2026, 4, 15, 0, 0, 0, 0, sydneyTZ) 	readings := uniformTodayReadings(dayStart, now, 3600) -	rawPeak, ok := livePeakGridImport(readings, now, "11:00", "14:00")+	rawPeak, ok := livePeakGridImport(readings, now, win("11:00", "14:00")) 	require.True(t, ok) 	wantPeak := derivedstats.RoundEnergy(rawPeak) @@ -298,7 +298,7 @@ func TestHandleStatusLivePeakGridImport(t *testing.T) { 			return &dynamo.DailyEnergyItem{Date: date, EInput: 3.0}, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())
internal/api/plan_window_test.go Added +461 / -0
diff --git a/internal/api/plan_window_test.go b/internal/api/plan_window_test.gonew file mode 100644index 0000000..6bc07a7--- /dev/null+++ b/internal/api/plan_window_test.go@@ -0,0 +1,461 @@+package api++import (+	"context"+	"encoding/json"+	"errors"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// This file covers requirement 4: the free window used by every off-peak+// feature comes from the plan pricing the day in question, and its absence is+// rendered as "no window" rather than falling back to a default.++// win builds a resolved free window from its "HH:MM" bounds, for the unit+// tests of the helpers that take one directly.+func win(start, end string) *offpeakWindow {+	startMin, ok := plan.ParseBandTime(start)+	if !ok {+		panic("bad window start: " + start)+	}+	endMin, ok := plan.ParseBandTime(end)+	if !ok {+		panic("bad window end: " + end)+	}+	return &offpeakWindow{startMin: startMin, endMin: endMin}+}++func freeBand(start, end string) dynamo.PricingWindow {+	return dynamo.PricingWindow{Start: start, End: end, Free: true}+}++func ratedBand(start, end string, rate float64) dynamo.PricingWindow {+	return dynamo.PricingWindow{Start: start, End: end, Rate: &rate}+}++// planRow builds a stored plan row pricing [start, end) with the given+// exception windows.+func planRow(id, start string, end *string, windows ...dynamo.PricingWindow) dynamo.PricingItem {+	savings := 0.15+	return dynamo.PricingItem{+		PricingID:            id,+		StartDate:            start,+		EndDate:              end,+		DefaultRate:          0.3,+		Windows:              windows,+		FeedInRate:           0.05,+		SavingsReferenceRate: &savings,+		CreatedAt:            "2026-01-01T00:00:00Z",+		UpdatedAt:            "2026-01-01T00:00:00Z",+	}+}++// storeWithPlans returns a pricing store holding exactly the given rows.+func storeWithPlans(rows ...dynamo.PricingItem) *fakePricingStore {+	s := newFakePricingStore()+	for _, row := range rows {+		s.rows[row.PricingID] = row+		if row.EndDate == nil {+			id := row.PricingID+			s.openEndedID = &id+		}+	}+	return s+}++// handlerWithPlans builds a handler over the given reader whose pricing store+// holds exactly the given plans.+func handlerWithPlans(reader dynamo.Reader, rows ...dynamo.PricingItem) *Handler {+	h := NewHandler(reader, nil, testSerial, testToken)+	h.SetPricingStore(storeWithPlans(rows...))+	return h+}++// steadyImportReadings returns one reading per minute from Sydney midnight on+// now's date up to and including now, all importing importW watts.+func steadyImportReadings(now time.Time, importW float64) []dynamo.ReadingItem {+	local := now.In(sydneyTZ)+	midnight := startOfDaySydney(local)+	var out []dynamo.ReadingItem+	for ts := midnight; !ts.After(local); ts = ts.Add(time.Minute) {+		out = append(out, dynamo.ReadingItem{+			Timestamp: ts.Unix(),+			Pgrid:     importW,+			Pload:     importW,+			Soc:       50,+		})+	}+	return out+}++func readerWithReadings(readings []dynamo.ReadingItem) *mockReader {+	return &mockReader{+		queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			return readings, nil+		},+	}+}++func TestStatus_OffpeakWindowComesFromThePlan(t *testing.T) {+	// AC 4.1: the window is the free band of the plan pricing today, not a+	// separately maintained configuration value.+	now := fixedNow()+	h := handlerWithPlans(&mockReader{}, planRow("p", "2026-01-01", nil, freeBand("10:00", "15:00")))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), statusRequest())+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	sr := parseStatusResponse(t, resp)+	require.NotNil(t, sr.Offpeak)+	assert.Equal(t, "10:00", sr.Offpeak.WindowStart)+	assert.Equal(t, "15:00", sr.Offpeak.WindowEnd)+}++func TestStatus_OffpeakNullWhenPlanHasNoFreeBand(t *testing.T) {+	// Q35 / AC 4.4: a plan without a free band has no window to report, and+	// clients must render that as "no window" rather than substituting the+	// legacy default.+	now := fixedNow()+	h := handlerWithPlans(&mockReader{}, planRow("p", "2026-01-01", nil, ratedBand("01:00", "06:00", 0.28)))+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), statusRequest())+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	sr := parseStatusResponse(t, resp)+	assert.Nil(t, sr.Offpeak, "a no-free-band day has no off-peak object")++	var raw map[string]json.RawMessage+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &raw))+	assert.JSONEq(t, `null`, string(raw["offpeak"]), "absence serialises as explicit null")+}++func TestStatus_OffpeakNullWhenNoPlanPricesToday(t *testing.T) {+	// AC 2.7 / AC 4.4: an unpriced day behaves as it does when no off-peak+	// data exists — values absent, never zero and never a default window.+	now := fixedNow()+	h := handlerWithPlans(&mockReader{}) // no plans at all+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), statusRequest())+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	sr := parseStatusResponse(t, resp)+	assert.Nil(t, sr.Offpeak)+}++func TestStatus_NoPlanLeavesOffpeakAndPeakAbsentNotZero(t *testing.T) {+	// AC 4.4: absent, not zero. With readings present the peak integration+	// would otherwise happily report a number derived from a guessed window.+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	readings := steadyImportReadings(now, 1000)+	mr := readerWithReadings(readings)+	mr.getOffpeakFn = func(_ context.Context, serial, date string) (*dynamo.OffpeakItem, error) {+		return &dynamo.OffpeakItem{SysSn: serial, Date: date, Status: dynamo.OffpeakStatusPending}, nil+	}+	h := handlerWithPlans(mr)+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), statusRequest())+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	sr := parseStatusResponse(t, resp)+	assert.Nil(t, sr.Offpeak)+	assert.Nil(t, sr.PeakGridImportKwh, "peak import needs a window to bracket, so it is absent too")+}++func TestStatus_PricingReadFailureReturns500(t *testing.T) {+	// Q14: a Lambda read failure must never be resolved as "no plan" — that+	// would silently strip a priced day's window and cost data.+	now := fixedNow()+	store := storeWithPlans(planRow("p", "2026-01-01", nil, freeBand("11:00", "14:00")))+	store.listErr = errors.New("dynamo down")+	h := NewHandler(&mockReader{}, nil, testSerial, testToken)+	h.SetPricingStore(store)+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), statusRequest())+	require.NoError(t, err)+	assert.Equal(t, 500, resp.StatusCode)+}++func TestStatus_CutoffSuppressionUsesSuccessorWindowOnSwitchEve(t *testing.T) {+	// AC 4.2 / Q11: on the eve of a plan switch the next charging window is+	// the successor's, so a cutoff landing inside it must be suppressed.+	//+	// now is 20:00 on 2026-07-31 (past today's window). At 55% SoC and 460 W+	// discharge against 13.34 kWh the projected cutoff is ~14.5 h out —+	// 10:30 on 2026-08-01, after the successor's 10:00 window start but+	// before the predecessor's 11:00 one.+	now := time.Date(2026, 7, 31, 20, 0, 0, 0, sydneyTZ)+	nowUnix := now.Unix()+	mr := readerWithReadings([]dynamo.ReadingItem{+		{Timestamp: nowUnix - 600, Pbat: 460, Pload: 500, Soc: 56},+		{Timestamp: nowUnix - 10, Pbat: 460, Pload: 500, Soc: 55},+	})+	mr.getSystemFn = func(_ context.Context, serial string) (*dynamo.SystemItem, error) {+		return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil+	}++	switchDate := "2026-08-01"+	predecessor := planRow("old", "2026-01-01", &switchDate, freeBand("11:00", "14:00"))++	t.Run("successor moves the window earlier and suppresses the cutoff", func(t *testing.T) {+		h := handlerWithPlans(mr, predecessor,+			planRow("new", switchDate, nil, freeBand("10:00", "15:00")))+		h.nowFunc = func() time.Time { return now }++		resp, err := h.Handle(context.Background(), statusRequest())+		require.NoError(t, err)+		require.Equal(t, 200, resp.StatusCode)++		sr := parseStatusResponse(t, resp)+		require.NotNil(t, sr.Battery)+		assert.Nil(t, sr.Battery.EstimatedCutoff,+			"cutoff at ~10:30 falls inside the successor's 10:00-15:00 window")+		require.NotNil(t, sr.Rolling15m)+		assert.Nil(t, sr.Rolling15m.EstimatedCutoff)+	})++	t.Run("successor keeping the same window leaves the cutoff visible", func(t *testing.T) {+		h := handlerWithPlans(mr, predecessor,+			planRow("new", switchDate, nil, freeBand("11:00", "14:00")))+		h.nowFunc = func() time.Time { return now }++		resp, err := h.Handle(context.Background(), statusRequest())+		require.NoError(t, err)+		require.Equal(t, 200, resp.StatusCode)++		sr := parseStatusResponse(t, resp)+		require.NotNil(t, sr.Battery)+		require.NotNil(t, sr.Battery.EstimatedCutoff,+			"cutoff at ~10:30 is before the 11:00 window start")+	})+}++func TestNextOffpeakStart_ResolvesPerDayPlans(t *testing.T) {+	switchDate := "2026-08-01"+	predecessor := planRow("old", "2026-01-01", &switchDate, freeBand("11:00", "14:00")).Plan()+	successor := planRow("new", switchDate, nil, freeBand("10:00", "15:00")).Plan()+	openEnded := planRow("only", "2026-01-01", nil, freeBand("11:00", "14:00")).Plan()+	noFreeBand := planRow("flat", "2026-01-01", nil, ratedBand("01:00", "06:00", 0.28)).Plan()++	cases := map[string]struct {+		now   time.Time+		plans []plan.Plan+		want  time.Time+		ok    bool+	}{+		"before today's window": {+			now:   time.Date(2026, 7, 20, 8, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{openEnded},+			want:  time.Date(2026, 7, 20, 11, 0, 0, 0, sydneyTZ),+			ok:    true,+		},+		"exactly at today's window start": {+			now:   time.Date(2026, 7, 20, 11, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{openEnded},+			want:  time.Date(2026, 7, 20, 11, 0, 0, 0, sydneyTZ),+			ok:    true,+		},+		"exactly at today's window end rolls to tomorrow": {+			now:   time.Date(2026, 7, 20, 14, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{openEnded},+			want:  time.Date(2026, 7, 21, 11, 0, 0, 0, sydneyTZ),+			ok:    true,+		},+		"inside today's window": {+			now:   time.Date(2026, 7, 20, 12, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{openEnded},+			want:  time.Date(2026, 7, 20, 11, 0, 0, 0, sydneyTZ),+			ok:    true,+		},+		"after today's window rolls to tomorrow": {+			now:   time.Date(2026, 7, 20, 20, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{openEnded},+			want:  time.Date(2026, 7, 21, 11, 0, 0, 0, sydneyTZ),+			ok:    true,+		},+		"switch eve takes the successor's window": {+			now:   time.Date(2026, 7, 31, 20, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{predecessor, successor},+			want:  time.Date(2026, 8, 1, 10, 0, 0, 0, sydneyTZ),+			ok:    true,+		},+		"switch day itself uses the successor": {+			now:   time.Date(2026, 8, 1, 8, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{predecessor, successor},+			want:  time.Date(2026, 8, 1, 10, 0, 0, 0, sydneyTZ),+			ok:    true,+		},+		"unpriced today still finds tomorrow's window": {+			now:   time.Date(2026, 7, 31, 20, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{successor},+			want:  time.Date(2026, 8, 1, 10, 0, 0, 0, sydneyTZ),+			ok:    true,+		},+		"no plan at all has no boundary": {+			now:   time.Date(2026, 7, 20, 8, 0, 0, 0, sydneyTZ),+			plans: nil,+		},+		"plan without a free band has no boundary": {+			now:   time.Date(2026, 7, 20, 8, 0, 0, 0, sydneyTZ),+			plans: []plan.Plan{noFreeBand},+		},+	}+	for name, tc := range cases {+		t.Run(name, func(t *testing.T) {+			got, ok := nextOffpeakStart(tc.now, tc.plans)+			require.Equal(t, tc.ok, ok)+			if tc.ok {+				assert.True(t, tc.want.Equal(got), "want %s, got %s", tc.want, got)+			}+		})+	}+}++func TestDay_TodayOffpeakSplitUsesThePlanWindow(t *testing.T) {+	// AC 4.1/4.3: today's live split integrates over the plan's free band.+	// A steady 1 kW import makes the expected value the window length in kWh,+	// so the 10:00-15:00 plan and the 11:00-14:00 plan give different answers.+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	date := "2026-04-15"+	mr := readerWithReadings(steadyImportReadings(now, 1000))+	mr.getOffpeakFn = func(_ context.Context, serial, d string) (*dynamo.OffpeakItem, error) {+		return &dynamo.OffpeakItem{SysSn: serial, Date: d, Status: dynamo.OffpeakStatusPending}, nil+	}+	mr.getDailyEnergyFn = func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+		return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 16}, nil+	}++	cases := map[string]struct {+		window dynamo.PricingWindow+		want   float64+	}{+		"legacy window":   {window: freeBand("11:00", "14:00"), want: 3},+		"new plan window": {window: freeBand("10:00", "15:00"), want: 5},+	}+	for name, tc := range cases {+		t.Run(name, func(t *testing.T) {+			h := handlerWithPlans(mr, planRow("p", "2026-01-01", nil, tc.window))+			h.nowFunc = func() time.Time { return now }++			req := makeRequest("GET", "/day", "Bearer "+testToken)+			req.QueryStringParameters = map[string]string{"date": date}+			resp, err := h.Handle(context.Background(), req)+			require.NoError(t, err)+			require.Equal(t, 200, resp.StatusCode)++			var dr DayDetailResponse+			require.NoError(t, json.Unmarshal([]byte(resp.Body), &dr))+			require.NotNil(t, dr.Summary)+			require.NotNil(t, dr.Summary.OffpeakGridImportKwh)+			assert.InDelta(t, tc.want, *dr.Summary.OffpeakGridImportKwh, 0.05)+		})+	}+}++func TestDay_NoPlanLeavesOffpeakValuesAbsent(t *testing.T) {+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	mr := readerWithReadings(steadyImportReadings(now, 1000))+	mr.getOffpeakFn = func(_ context.Context, serial, d string) (*dynamo.OffpeakItem, error) {+		return &dynamo.OffpeakItem{SysSn: serial, Date: d, Status: dynamo.OffpeakStatusPending}, nil+	}+	mr.getDailyEnergyFn = func(_ context.Context, serial, d string) (*dynamo.DailyEnergyItem, error) {+		return &dynamo.DailyEnergyItem{SysSn: serial, Date: d, EInput: 16}, nil+	}+	h := handlerWithPlans(mr)+	h.nowFunc = func() time.Time { return now }++	req := makeRequest("GET", "/day", "Bearer "+testToken)+	req.QueryStringParameters = map[string]string{"date": "2026-04-15"}+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	var dr DayDetailResponse+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &dr))+	require.NotNil(t, dr.Summary)+	assert.Nil(t, dr.Summary.OffpeakGridImportKwh, "absent, not zero")+	assert.Nil(t, dr.Summary.OffpeakGridExportKwh)+	assert.Nil(t, dr.Summary.PeakGridImportKwh)+}++func TestDay_PricingReadFailureReturns500(t *testing.T) {+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	store := storeWithPlans(planRow("p", "2026-01-01", nil, freeBand("11:00", "14:00")))+	store.listErr = errors.New("dynamo down")+	h := NewHandler(&mockReader{}, nil, testSerial, testToken)+	h.SetPricingStore(store)+	h.nowFunc = func() time.Time { return now }++	req := makeRequest("GET", "/day", "Bearer "+testToken)+	req.QueryStringParameters = map[string]string{"date": "2026-04-15"}+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, 500, resp.StatusCode)+}++func TestHistory_TodayRowUsesThePlanWindow(t *testing.T) {+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	date := "2026-04-15"+	mr := readerWithReadings(steadyImportReadings(now, 1000))+	mr.queryDailyEnergyFn = func(_ context.Context, serial, _, _ string) ([]dynamo.DailyEnergyItem, error) {+		return []dynamo.DailyEnergyItem{{SysSn: serial, Date: date, EInput: 16}}, nil+	}+	mr.queryOffpeakFn = func(_ context.Context, serial, _, _ string) ([]dynamo.OffpeakItem, error) {+		return []dynamo.OffpeakItem{{SysSn: serial, Date: date, Status: dynamo.OffpeakStatusPending}}, nil+	}++	cases := map[string]struct {+		window dynamo.PricingWindow+		want   float64+	}{+		"legacy window":   {window: freeBand("11:00", "14:00"), want: 3},+		"new plan window": {window: freeBand("10:00", "15:00"), want: 5},+	}+	for name, tc := range cases {+		t.Run(name, func(t *testing.T) {+			h := handlerWithPlans(mr, planRow("p", "2026-01-01", nil, tc.window))+			h.nowFunc = func() time.Time { return now }++			req := makeRequest("GET", "/history", "Bearer "+testToken)+			req.QueryStringParameters = map[string]string{"days": "1"}+			resp, err := h.Handle(context.Background(), req)+			require.NoError(t, err)+			require.Equal(t, 200, resp.StatusCode)++			var hr HistoryResponse+			require.NoError(t, json.Unmarshal([]byte(resp.Body), &hr))+			require.Len(t, hr.Days, 1)+			require.NotNil(t, hr.Days[0].OffpeakGridImportKwh)+			assert.InDelta(t, tc.want, *hr.Days[0].OffpeakGridImportKwh, 0.05)+		})+	}+}++func TestHistory_PricingReadFailureReturns500(t *testing.T) {+	now := time.Date(2026, 4, 15, 16, 0, 0, 0, sydneyTZ)+	store := storeWithPlans(planRow("p", "2026-01-01", nil, freeBand("11:00", "14:00")))+	store.listErr = errors.New("dynamo down")+	h := NewHandler(&mockReader{}, nil, testSerial, testToken)+	h.SetPricingStore(store)+	h.nowFunc = func() time.Time { return now }++	req := makeRequest("GET", "/history", "Bearer "+testToken)+	req.QueryStringParameters = map[string]string{"days": "7"}+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, 500, resp.StatusCode)+}
internal/api/pricing_handler.go Modified +213 / -177
diff --git a/internal/api/pricing_handler.go b/internal/api/pricing_handler.goindex 4d4987f..e014f1c 100644--- a/internal/api/pricing_handler.go+++ b/internal/api/pricing_handler.go@@ -11,26 +11,45 @@ import ( 	"time"  	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" ) -// pricingPayload is the wire shape of POST /pricing and PUT-// /pricing/{id}. JSON numbers decode into float64 unchanged; the-// validator rejects > 4 decimal places before rounding.+// pricingPayload is the wire shape of POST /pricing and PUT /pricing/{id}.+//+// The plan is transmitted as entered — a default rate plus the exception+// windows that deviate from it (Decision 4) — and endDate is the exclusive+// switch date (Decision 5), stored verbatim. Rates are pointers so a missing+// field is distinguishable from an explicit zero; the validator rejects more+// than 4 decimal places before anything is rounded. type pricingPayload struct {-	StartDate          string   `json:"startDate"`-	EndDate            *string  `json:"endDate,omitempty"`-	PeakRate           *float64 `json:"peakRate"`-	FeedInRate         *float64 `json:"feedInRate"`-	OffPeakSavingsRate *float64 `json:"offPeakSavingsRate"`+	StartDate            string          `json:"startDate"`+	EndDate              *string         `json:"endDate,omitempty"`+	DefaultRate          *float64        `json:"defaultRate"`+	Windows              []windowPayload `json:"windows"`+	FeedInRate           *float64        `json:"feedInRate"`+	SavingsReferenceRate *float64        `json:"savingsReferenceRate,omitempty"`+}++// windowPayload is one exception window. Rate is absent on a free window.+type windowPayload struct {+	Start string   `json:"start"`+	End   string   `json:"end"`+	Free  bool     `json:"free"`+	Rate  *float64 `json:"rate,omitempty"` }  // replaceOpenEndedPayload is the wire shape of-// POST /pricing/replace-open-ended.+// POST /pricing/replace-open-ended. NewPeriod stays raw so the legacy-shape+// check below can inspect its keys before decoding. type replaceOpenEndedPayload struct {-	ClosingPricingID string         `json:"closingPricingId"`-	NewPeriod        pricingPayload `json:"newPeriod"`+	ClosingPricingID string          `json:"closingPricingId"`+	NewPeriod        json.RawMessage `json:"newPeriod"` } +// legacyPayloadMarker is the field whose presence identifies a pre-migration+// three-rate payload. The band shape has no such field.+const legacyPayloadMarker = "peakRate"+ // SetPricingStore wires the pricing CRUD dependency. Called by // cmd/api/main.go and rebuilds the mux so the routes pick up the store. func (h *Handler) SetPricingStore(s PricingStore) {@@ -38,19 +57,21 @@ func (h *Handler) SetPricingStore(s PricingStore) { }  // pricingError is the JSON response shape for every pricing error.-// `openEndedId` is populated only when the offending row of an overlap-// is the unique open-ended period — the editor needs the id to surface-// the one-tap remediation from AC 3.6.+//+// `conflictingPricingId` names the plan an overlap collides with (AC 2.5).+// `openEndedId` is populated only when that offender is the unique+// open-ended plan — the editor needs the id to surface the one-tap+// remediation from AC 6.5. type pricingError struct {-	Error       string `json:"error"`-	Message     string `json:"message"`-	OpenEndedID string `json:"openEndedId,omitempty"`+	Error                string `json:"error"`+	Message              string `json:"message"`+	OpenEndedID          string `json:"openEndedId,omitempty"`+	ConflictingPricingID string `json:"conflictingPricingId,omitempty"` }  // writePricingError serialises a {"error","message"} response with the-// given HTTP status. Unlike writeJSONError, this carries the AC 2.3-// machine-parseable code in the "error" field and the human-readable-// description in "message".+// given HTTP status. Unlike writeJSONError, this carries the machine-parseable+// code in the "error" field and the human-readable description in "message". func writePricingError(w http.ResponseWriter, status int, code, message string) { 	body, err := json.Marshal(pricingError{Error: code, Message: message}) 	if err != nil {@@ -62,27 +83,29 @@ func writePricingError(w http.ResponseWriter, status int, code, message string) 	_, _ = w.Write(body) } -// writePricingOverlapError surfaces the offending open-ended row id when-// the offender is the unique open-ended period; otherwise behaves-// exactly like writePricingError. The id powers the editor's AC 3.6-// one-tap remediation flow.-func writePricingOverlapError(w http.ResponseWriter, openEndedID string) {-	body, err := json.Marshal(pricingError{-		Error:       pricingCodeOverlap,-		Message:     "pricing period overlaps an existing one",-		OpenEndedID: openEndedID,-	})+// writePricingOverlapError names the plan the candidate collides with, and+// additionally surfaces it as `openEndedId` when that plan is the unique+// open-ended one — the id that powers the editor's remediation flow.+func writePricingOverlapError(w http.ResponseWriter, conflictingID string, isOpenEnded bool) {+	payload := pricingError{+		Error:                pricingCodeOverlap,+		Message:              "pricing plan overlaps existing plan " + conflictingID,+		ConflictingPricingID: conflictingID,+	}+	if isOpenEnded {+		payload.OpenEndedID = conflictingID+	}+	body, err := json.Marshal(payload) 	if err != nil { 		slog.Error("marshal pricing overlap error", "error", err)-		body = []byte(`{"error":"overlap","message":"pricing period overlaps an existing one"}`)+		body = []byte(`{"error":"overlap","message":"pricing plan overlaps an existing one"}`) 	} 	w.Header().Set("Content-Type", "application/json") 	w.WriteHeader(http.StatusBadRequest) 	_, _ = w.Write(body) } -// handleListPricing returns every pricing period sorted by startDate-// ascending. AC 2.5.+// handleListPricing returns every pricing plan sorted by startDate ascending. func (h *Handler) handleListPricing(w http.ResponseWriter, r *http.Request) { 	if h.pricing == nil { 		writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "pricing store not configured")@@ -99,8 +122,8 @@ func (h *Handler) handleListPricing(w http.ResponseWriter, r *http.Request) { 	}{Pricing: rows}) } -// handleCreatePricing validates the payload, enforces AC 1.10 ordering,-// assigns server-side id/timestamps, and writes the row.+// handleCreatePricing validates the payload, assigns server-side+// id/timestamps, and writes the row. func (h *Handler) handleCreatePricing(w http.ResponseWriter, r *http.Request) { 	if h.pricing == nil { 		writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "pricing store not configured")@@ -136,8 +159,7 @@ func (h *Handler) handleCreatePricing(w http.ResponseWriter, r *http.Request) { }  // handleUpdatePricing validates and overwrites an existing pricing row.-// AC 1.7 / Decision 17: the row being updated is excluded from the-// overlap check.+// Decision 17: the row being updated is excluded from the overlap check. func (h *Handler) handleUpdatePricing(w http.ResponseWriter, r *http.Request) { 	if h.pricing == nil { 		writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "pricing store not configured")@@ -167,7 +189,7 @@ func (h *Handler) handleUpdatePricing(w http.ResponseWriter, r *http.Request) { 		} 	} 	if current == nil {-		writePricingError(w, http.StatusNotFound, pricingCodeNotFound, "pricing period not found")+		writePricingError(w, http.StatusNotFound, pricingCodeNotFound, "pricing plan not found") 		return 	} 	if !runPricingValidationChain(w, payload, existing, id) {@@ -188,8 +210,8 @@ func (h *Handler) handleUpdatePricing(w http.ResponseWriter, r *http.Request) { 	writeJSON(w, http.StatusOK, item) } -// handleDeletePricing removes a pricing row by id. AC 2.4 / Decision 11:-// 404 on unknown id, 204 on success.+// handleDeletePricing removes a pricing row by id. Decision 11: 404 on+// unknown id, 204 on success. func (h *Handler) handleDeletePricing(w http.ResponseWriter, r *http.Request) { 	if h.pricing == nil { 		writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "pricing store not configured")@@ -207,7 +229,7 @@ func (h *Handler) handleDeletePricing(w http.ResponseWriter, r *http.Request) { 		return 	} 	if existing == nil {-		writePricingError(w, http.StatusNotFound, pricingCodeNotFound, "pricing period not found")+		writePricingError(w, http.StatusNotFound, pricingCodeNotFound, "pricing plan not found") 		return 	} 	prevOpenEndedID, ok := loadPrevOpenEndedID(w, r.Context(), h.pricing, "delete")@@ -222,9 +244,10 @@ func (h *Handler) handleDeletePricing(w http.ResponseWriter, r *http.Request) { 	w.WriteHeader(http.StatusNoContent) } -// handleReplaceOpenEnded atomically closes the existing open-ended row-// at startDate − 1 day and inserts a new pricing row (AC 2.6). The-// closing-row endDate is derived server-side per AC 3.6.+// handleReplaceOpenEnded atomically closes the existing open-ended plan on the+// successor's start date and inserts the successor (AC 2.2/2.6). Both rows+// carry the same literal date: the predecessor's exclusive end is the+// successor's inclusive start, so the switch day belongs to the successor. func (h *Handler) handleReplaceOpenEnded(w http.ResponseWriter, r *http.Request) { 	if h.pricing == nil { 		writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "pricing store not configured")@@ -245,6 +268,10 @@ func (h *Handler) handleReplaceOpenEnded(w http.ResponseWriter, r *http.Request) 		writePricingError(w, http.StatusBadRequest, pricingCodeBadRequest, "closingPricingId required") 		return 	}+	newPeriod, ok := parsePricingPayload(w, payload.NewPeriod)+	if !ok {+		return+	}  	existing, err := h.pricing.ListPricing(r.Context()) 	if err != nil {@@ -260,33 +287,40 @@ func (h *Handler) handleReplaceOpenEnded(w http.ResponseWriter, r *http.Request) 		} 	} 	if closing == nil {-		writePricingError(w, http.StatusNotFound, pricingCodeNotFound, "closing pricing period not found")+		writePricingError(w, http.StatusNotFound, pricingCodeNotFound, "closing pricing plan not found") 		return 	} 	if closing.EndDate != nil {-		writePricingError(w, http.StatusBadRequest, pricingCodeSecondOpenEnded, "closing period is not open-ended")+		writePricingError(w, http.StatusBadRequest, pricingCodeSecondOpenEnded, "closing plan is not open-ended") 		return 	} -	// Format-validate the new period's startDate up front so a malformed-	// value surfaces with the same error code the validation chain uses-	// elsewhere, not as a muddied "newPeriod.startDate invalid". The full-	// validation chain runs below against the projected post-write state.-	if !validISODate(payload.NewPeriod.StartDate) {+	// Format-validate the successor's startDate up front so a malformed value+	// surfaces with the same error code the validation chain uses elsewhere,+	// not as a muddied "newPeriod.startDate invalid". The full chain runs+	// below against the projected post-write state.+	if !plan.ValidDate(newPeriod.StartDate) { 		writePricingError(w, http.StatusBadRequest, pricingCodeInvertedDates, "newPeriod.startDate must be YYYY-MM-DD") 		return 	}-	closingEndDate, err := previousDate(payload.NewPeriod.StartDate)-	if err != nil {-		// Defensive — validISODate above should have caught this.-		slog.Error("previousDate failed on a validISODate-passing input", "startDate", payload.NewPeriod.StartDate, "error", err)-		writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "internal error computing closing date")+	// Under exclusive end dates the closing row ends on the successor's start+	// date — the same literal string, no ±1 arithmetic (Decision 5).+	closingEndDate := newPeriod.StartDate++	// The validation chain below only ever sees the successor payload, so the+	// projected closing row's own dates have to be checked here. A successor+	// starting at or before the closing plan's start date would cap it at+	// endDate <= startDate — the zero-day/inverted plan plan.Validate rejects on+	// every other write path. The overlap check cannot catch it: a zero-day+	// half-open range intersects nothing.+	if closingEndDate <= closing.StartDate {+		writePricingError(w, http.StatusBadRequest, pricingCodeInvertedDates,+			"newPeriod.startDate must be after the closing plan's startDate") 		return 	} -	// Simulate the resulting two-row state (closing capped at the new-	// startDate − 1 day; new row inserted) and run the same validation-	// chain against it.+	// Simulate the resulting two-row state (closing capped at the switch+	// date; new row inserted) and run the same validation chain against it. 	projected := make([]dynamo.PricingItem, 0, len(existing)+1) 	for _, row := range existing { 		if row.PricingID == payload.ClosingPricingID {@@ -298,12 +332,12 @@ func (h *Handler) handleReplaceOpenEnded(w http.ResponseWriter, r *http.Request) 			projected = append(projected, row) 		} 	}-	if !runPricingValidationChain(w, payload.NewPeriod, projected, "") {+	if !runPricingValidationChain(w, newPeriod, projected, "") { 		return 	}  	now := h.nowFunc().UTC().Format(time.RFC3339)-	newItem := payload.NewPeriod.toItem(h.idFunc(), now, now)+	newItem := newPeriod.toItem(h.idFunc(), now, now) 	if err := h.pricing.ReplaceOpenEnded(r.Context(), payload.ClosingPricingID, closingEndDate, now, newItem); err != nil { 		mapPricingStoreError(w, "replace open-ended pricing", err) 		return@@ -323,8 +357,8 @@ func (h *Handler) handleReplaceOpenEnded(w http.ResponseWriter, r *http.Request) 	}{Pricing: []dynamo.PricingItem{closingRow, newItem}}) } -// decodePricingPayload reads, size-limits, and JSON-decodes the request-// body. On failure the response is already written; callers return early.+// decodePricingPayload reads, size-limits, and decodes the request body. On+// failure the response is already written; callers return early. func decodePricingPayload(w http.ResponseWriter, r *http.Request) (pricingPayload, bool) { 	r.Body = http.MaxBytesReader(w, r.Body, pricingBodyMaxBytes) 	body, err := io.ReadAll(r.Body)@@ -332,28 +366,98 @@ func decodePricingPayload(w http.ResponseWriter, r *http.Request) (pricingPayloa 		writePricingError(w, http.StatusBadRequest, pricingCodeBadRequest, "malformed request body") 		return pricingPayload{}, false 	}+	return parsePricingPayload(w, body)+}++// parsePricingPayload decodes one plan payload, rejecting the legacy+// three-rate shape first (AC 7.3).+//+// Detection runs on the raw JSON keys because encoding/json silently drops+// unknown fields: a legacy body would otherwise decode into pricingPayload as+// a windowless plan with every rate at zero, which is a valid band plan and+// would be stored as one.+func parsePricingPayload(w http.ResponseWriter, raw []byte) (pricingPayload, bool) {+	var keys map[string]json.RawMessage+	if err := json.Unmarshal(raw, &keys); err != nil {+		writePricingError(w, http.StatusBadRequest, pricingCodeBadRequest, "malformed request body")+		return pricingPayload{}, false+	}+	if _, legacy := keys[legacyPayloadMarker]; legacy {+		writePricingError(w, http.StatusBadRequest, pricingCodeLegacyShape,+			"three-rate pricing plans are no longer accepted; send defaultRate and windows")+		return pricingPayload{}, false+	} 	var payload pricingPayload-	if err := json.Unmarshal(body, &payload); err != nil {+	if err := json.Unmarshal(raw, &payload); err != nil { 		writePricingError(w, http.StatusBadRequest, pricingCodeBadRequest, "malformed request body") 		return pricingPayload{}, false 	} 	return payload, true } -// toItem builds a PricingItem from the wire payload + server-assigned-// fields. Rates are rounded to exactly four decimal places per-// Decision 10 / Decision 20.+// domainPlan converts the wire payload into the domain plan the validation and+// segmentation helpers operate on. Rates pass through unrounded: the precision+// rule fires on what the client actually sent, so rounding here would make it+// unfireable.+func (p pricingPayload) domainPlan(id string) plan.Plan {+	windows := make([]plan.Window, len(p.Windows))+	for i, w := range p.Windows {+		windows[i] = plan.Window{Start: w.Start, End: w.End, Free: w.Free}+		if !w.Free {+			windows[i].Rate = deref(w.Rate)+		}+	}+	end := ""+	if p.EndDate != nil {+		end = *p.EndDate+	}+	result := plan.Plan{+		ID:          id,+		StartDate:   p.StartDate,+		EndDate:     end,+		DefaultRate: deref(p.DefaultRate),+		Windows:     windows,+		FeedInRate:  deref(p.FeedInRate),+	}+	if p.SavingsReferenceRate != nil {+		savings := *p.SavingsReferenceRate+		result.SavingsRefRate = &savings+	}+	return result+}++// toItem builds the storage row from the wire payload plus the server-assigned+// id and timestamps. Rates are normalised to exactly four decimal places+// (Decision 10 / Decision 20) — validation has already rejected anything+// finer, so this only removes float representation noise. func (p pricingPayload) toItem(id, createdAt, updatedAt string) dynamo.PricingItem {-	return dynamo.PricingItem{-		PricingID:          id,-		StartDate:          p.StartDate,-		EndDate:            p.EndDate,-		PeakRate:           roundTo4DP(deref(p.PeakRate)),-		FeedInRate:         roundTo4DP(deref(p.FeedInRate)),-		OffPeakSavingsRate: roundTo4DP(deref(p.OffPeakSavingsRate)),-		CreatedAt:          createdAt,-		UpdatedAt:          updatedAt,+	dp := p.domainPlan(id)+	windows := make([]dynamo.PricingWindow, len(dp.Windows))+	for i, w := range dp.Windows {+		windows[i] = dynamo.PricingWindow{Start: w.Start, End: w.End, Free: w.Free}+		if !w.Free {+			rate := roundTo4DP(w.Rate)+			windows[i].Rate = &rate+		}+	}+	item := dynamo.PricingItem{+		PricingID:   id,+		StartDate:   dp.StartDate,+		DefaultRate: roundTo4DP(dp.DefaultRate),+		Windows:     windows,+		FeedInRate:  roundTo4DP(dp.FeedInRate),+		CreatedAt:   createdAt,+		UpdatedAt:   updatedAt,+	}+	if dp.SavingsRefRate != nil {+		savings := roundTo4DP(*dp.SavingsRefRate)+		item.SavingsReferenceRate = &savings 	}+	if dp.EndDate != "" {+		end := dp.EndDate+		item.EndDate = &end+	}+	return item }  func deref(p *float64) float64 {@@ -363,54 +467,39 @@ func deref(p *float64) float64 { 	return *p } -// runPricingValidationChain executes AC 1.10 in order:-// inverted_dates → overlap → rate_precision → rate_out_of_range →-// second_open_ended. Writes the first failure and returns false; on-// success returns true with no response written.+// runPricingValidationChain reports the first violated rule, in the order+// inverted_dates → overlap → the remaining single-plan band rules →+// second_open_ended. Writes the failure and returns false; on success returns+// true with no response written.+//+// The single-plan rules come from plan.Validate, which sees one plan at a+// time; the date-range overlap and single-open-ended rules need the whole plan+// set and so are checked here. Date validity is pulled ahead of the overlap+// check because an unparseable date makes the range comparison meaningless. // // `excludeID` is the id of the row being updated; empty on create. func runPricingValidationChain(w http.ResponseWriter, p pricingPayload, existing []dynamo.PricingItem, excludeID string) bool {-	if !validateInvertedDates(w, p) {-		return false+	errs := p.domainPlan(excludeID).Validate()+	for _, e := range errs {+		if e.Code == plan.CodeInvertedDates {+			writePricingError(w, http.StatusBadRequest, e.Code, e.Message)+			return false+		} 	} 	if !validateOverlap(w, p, existing, excludeID) { 		return false 	}-	if !validateRatePrecision(w, p) {+	if len(errs) > 0 {+		writePricingError(w, http.StatusBadRequest, errs[0].Code, errs[0].Message) 		return false 	}-	if !validateRateRange(w, p) {-		return false-	}-	if !validateSecondOpenEnded(w, p, existing, excludeID) {-		return false-	}-	return true+	return validateSecondOpenEnded(w, p, existing, excludeID) } -// validateInvertedDates fires AC 1.6 when endDate is present and-// strictly before startDate. A YYYY-MM-DD string compare is correct-// because the format is lexicographically chronological.-func validateInvertedDates(w http.ResponseWriter, p pricingPayload) bool {-	if !validISODate(p.StartDate) {-		writePricingError(w, http.StatusBadRequest, pricingCodeInvertedDates, "startDate must be YYYY-MM-DD")-		return false-	}-	if p.EndDate != nil {-		if !validISODate(*p.EndDate) {-			writePricingError(w, http.StatusBadRequest, pricingCodeInvertedDates, "endDate must be YYYY-MM-DD")-			return false-		}-		if *p.EndDate < p.StartDate {-			writePricingError(w, http.StatusBadRequest, pricingCodeInvertedDates, "endDate must not precede startDate")-			return false-		}-	}-	return true-}--// validateOverlap fires AC 1.7 when the candidate's date range-// intersects any existing row's date range (excluding excludeID).+// validateOverlap fires when the candidate's date range intersects any+// existing plan's range (excluding excludeID), naming the offender per AC 2.5.+// Both sides are half-open [start, end) with exclusive end dates, so a plan+// ending on the day its successor starts does not overlap it (AC 2.2). // Open-ended is modelled as endDate = "9999-12-31". func validateOverlap(w http.ResponseWriter, p pricingPayload, existing []dynamo.PricingItem, excludeID string) bool { 	candEnd := pricingMaxEndDate@@ -425,49 +514,17 @@ func validateOverlap(w http.ResponseWriter, p pricingPayload, existing []dynamo. 		if row.EndDate != nil { 			rowEnd = *row.EndDate 		}-		// Half-open intervals overlap iff start ≤ other.end && end ≥-		// other.start. Inclusive on both ends per AC 1.5.-		if p.StartDate <= rowEnd && candEnd >= row.StartDate {-			if row.EndDate == nil {-				writePricingOverlapError(w, row.PricingID)-				return false-			}-			writePricingError(w, http.StatusBadRequest, pricingCodeOverlap, "pricing period overlaps an existing one")+		// Half-open intervals intersect iff each starts before the other ends.+		if p.StartDate < rowEnd && candEnd > row.StartDate {+			writePricingOverlapError(w, row.PricingID, row.EndDate == nil) 			return false 		} 	} 	return true } -// validateRatePrecision fires AC 1.4 when any rate has > 4 decimal-// places. Float64 round-tripping is precise enough at 4 dp that-// multiplying by 10000 and comparing against the nearest integer is-// safe — Decision 20.-func validateRatePrecision(w http.ResponseWriter, p pricingPayload) bool {-	for _, rate := range []float64{deref(p.PeakRate), deref(p.FeedInRate), deref(p.OffPeakSavingsRate)} {-		scaled := rate * 10000-		if math.Abs(scaled-math.Round(scaled)) > 1e-6 {-			writePricingError(w, http.StatusBadRequest, pricingCodeRatePrecision, "rates must have at most 4 decimal places")-			return false-		}-	}-	return true-}--// validateRateRange fires AC 1.8 when any rate is < 0 or > the cap.-func validateRateRange(w http.ResponseWriter, p pricingPayload) bool {-	for _, rate := range []float64{deref(p.PeakRate), deref(p.FeedInRate), deref(p.OffPeakSavingsRate)} {-		if rate < 0 || rate > pricingRateCap {-			writePricingError(w, http.StatusBadRequest, pricingCodeRateOutOfRange, "rates must be between 0 and 10.0 AUD per kWh")-			return false-		}-	}-	return true-}--// validateSecondOpenEnded fires AC 1.9 when the candidate is-// open-ended and another existing row (other than excludeID) is also-// open-ended.+// validateSecondOpenEnded fires when the candidate is open-ended and another+// existing row (other than excludeID) is also open-ended. func validateSecondOpenEnded(w http.ResponseWriter, p pricingPayload, existing []dynamo.PricingItem, excludeID string) bool { 	if p.EndDate != nil { 		return true@@ -477,7 +534,7 @@ func validateSecondOpenEnded(w http.ResponseWriter, p pricingPayload, existing [ 			continue 		} 		if row.EndDate == nil {-			writePricingError(w, http.StatusBadRequest, pricingCodeSecondOpenEnded, "another pricing period is already open-ended")+			writePricingError(w, http.StatusBadRequest, pricingCodeSecondOpenEnded, "another pricing plan is already open-ended") 			return false 		} 	}@@ -511,6 +568,11 @@ func mapPricingStoreError(w http.ResponseWriter, op string, err error) { 	switch { 	case errors.Is(err, dynamo.ErrPricingConcurrentWrite): 		writePricingError(w, http.StatusConflict, pricingCodeConcurrentWrite, "concurrent open-ended write detected")+	case errors.Is(err, dynamo.ErrPricingLegacyShape):+		// Q32: succession refuses to patch a not-yet-migrated closing row.+		slog.Warn("pricing succession blocked by legacy row", "op", op, "error", err)+		writePricingError(w, http.StatusBadRequest, pricingCodeLegacyShape,+			"the plan being closed is still the legacy three-rate shape; run the pricing migration first") 	case errors.Is(err, dynamo.ErrPricingUUIDCollision): 		slog.Warn("pricing uuid collision", "op", op, "error", err) 		writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "uuid collision; retry")@@ -520,32 +582,6 @@ func mapPricingStoreError(w http.ResponseWriter, op string, err error) { 	} } -// validISODate returns true when s parses as YYYY-MM-DD in-// Australia/Melbourne. We don't load the location at runtime — the-// canonical layout match is sufficient for the wire format check.-func validISODate(s string) bool {-	if len(s) != 10 {-		return false-	}-	// Cheap structural check before time.Parse so common malformed-	// inputs fail fast.-	if s[4] != '-' || s[7] != '-' {-		return false-	}-	_, err := time.Parse("2006-01-02", s)-	return err == nil-}--// previousDate returns the YYYY-MM-DD string for one calendar day before-// `s`. Used by replace-open-ended to derive the closing row's endDate.-func previousDate(s string) (string, error) {-	t, err := time.Parse("2006-01-02", s)-	if err != nil {-		return "", err-	}-	return t.AddDate(0, 0, -1).Format("2006-01-02"), nil-}- // roundTo4DP rounds v to exactly four decimal places. Used to normalise // accepted rates on write so 0.28729999… stored as 0.2873. func roundTo4DP(v float64) float64 {
internal/api/pricing_test.go Modified +341 / -185
diff --git a/internal/api/pricing_test.go b/internal/api/pricing_test.goindex cf8e565..6ea1181 100644--- a/internal/api/pricing_test.go+++ b/internal/api/pricing_test.go@@ -6,6 +6,7 @@ import ( 	"errors" 	"fmt" 	"net/http"+	"strings" 	"sync" 	"testing" 	"time"@@ -153,7 +154,7 @@ func (s *fakePricingStore) ReplaceOpenEnded(_ context.Context, closingID, closin  func sortPricingByStart(rows []dynamo.PricingItem) { 	// Bubble sort is sufficient: tests use at most ~5 rows.-	for i := 0; i < len(rows); i++ {+	for i := range rows { 		for j := i + 1; j < len(rows); j++ { 			if rows[j].StartDate < rows[i].StartDate { 				rows[i], rows[j] = rows[j], rows[i]@@ -162,8 +163,28 @@ func sortPricingByStart(rows []dynamo.PricingItem) { 	} } +// bandPlanRow builds a stored band-shape row for the fixtures below: a single+// free window over the legacy 11:00–14:00 hours plus a default rate covering+// the rest of the day.+func bandPlanRow(id, start string, end *string, defaultRate float64) dynamo.PricingItem {+	savings := 0.15+	return dynamo.PricingItem{+		PricingID:            id,+		StartDate:            start,+		EndDate:              end,+		DefaultRate:          defaultRate,+		Windows:              []dynamo.PricingWindow{{Start: "11:00", End: "14:00", Free: true}},+		FeedInRate:           0.05,+		SavingsReferenceRate: &savings,+		CreatedAt:            "2026-05-23T10:00:00Z",+		UpdatedAt:            "2026-05-23T10:00:00Z",+	}+}++func strPtr(s string) *string { return &s }+ func newPricingTestHandler(store *fakePricingStore) *Handler {-	h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(&mockReader{}, nil, testSerial, testToken) 	h.SetPricingStore(store) 	fixedNow := time.Date(2026, 5, 23, 10, 0, 0, 0, time.UTC) 	h.nowFunc = func() time.Time { return fixedNow }@@ -183,11 +204,13 @@ func makeJSONRequest(method, path, body string) events.LambdaFunctionURLRequest 	return req } -// Generic typed-error shape used to inspect the {"error","message"}-// response body.+// Generic typed-error shape used to inspect the+// {"error","message","conflictingPricingId"} response body. type pricingErrorBody struct {-	Error   string `json:"error"`-	Message string `json:"message"`+	Error                string `json:"error"`+	Message              string `json:"message"`+	OpenEndedID          string `json:"openEndedId"`+	ConflictingPricingID string `json:"conflictingPricingId"` }  func decodeError(t *testing.T, raw string) pricingErrorBody {@@ -197,11 +220,30 @@ func decodeError(t *testing.T, raw string) pricingErrorBody { 	return body } +// The incoming time-of-use plan (Q3): free 10:00–15:00, a cheaper 01:00–06:00+// band, and the default rate for the rest of the day.+const newPlanBody = `{"startDate":"2026-08-01",` ++	`"defaultRate":0.35,` ++	`"windows":[{"start":"10:00","end":"15:00","free":true},` ++	`{"start":"01:00","end":"06:00","free":false,"rate":0.28}],` ++	`"feedInRate":0.05,"savingsReferenceRate":0.35}`++// singleBandBody is the migrated shape of a legacy plan: free 11:00–14:00 and+// one flat default rate. Callers substitute the dates.+func singleBandBody(startDate string, endDate *string) string {+	end := "null"+	if endDate != nil {+		end = `"` + *endDate + `"`+	}+	return fmt.Sprintf(`{"startDate":%q,"endDate":%s,"defaultRate":0.3,`++		`"windows":[{"start":"11:00","end":"14:00","free":true}],`++		`"feedInRate":0.05,"savingsReferenceRate":0.15}`, startDate, end)+}+ func TestPricing_ListReturnsSortedByStartDate(t *testing.T) { 	store := newFakePricingStore()-	end := "2026-12-31"-	store.rows["p-b"] = dynamo.PricingItem{PricingID: "p-b", StartDate: "2026-06-01", EndDate: &end, PeakRate: 0.3, FeedInRate: 0.05, OffPeakSavingsRate: 0.1, CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z"}-	store.rows["p-a"] = dynamo.PricingItem{PricingID: "p-a", StartDate: "2026-01-01", EndDate: &end, PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08, CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z"}+	store.rows["p-b"] = bandPlanRow("p-b", "2026-06-01", strPtr("2027-01-01"), 0.3)+	store.rows["p-a"] = bandPlanRow("p-a", "2026-01-01", strPtr("2026-06-01"), 0.25) 	h := newPricingTestHandler(store)  	resp, err := h.Handle(context.Background(), makeRequest(http.MethodGet, "/pricing", "Bearer "+testToken))@@ -215,6 +257,8 @@ func TestPricing_ListReturnsSortedByStartDate(t *testing.T) { 	require.Len(t, body.Pricing, 2) 	assert.Equal(t, "p-a", body.Pricing[0].PricingID, "AC 2.5: sorted by startDate ascending") 	assert.Equal(t, "p-b", body.Pricing[1].PricingID)+	require.Len(t, body.Pricing[0].Windows, 1, "band windows must round-trip on the wire")+	assert.True(t, body.Pricing[0].Windows[0].Free) }  func TestPricing_ListReturns401WithoutToken(t *testing.T) {@@ -226,33 +270,60 @@ func TestPricing_ListReturns401WithoutToken(t *testing.T) { 	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } -func TestPricing_CreateClosedPeriodAssignsIDAndTimestamps(t *testing.T) {+func TestPricing_CreateBandPlanRoundTrips(t *testing.T) { 	store := newFakePricingStore() 	h := newPricingTestHandler(store) -	body := `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.2873,"feedInRate":0.05,"offPeakSavingsRate":0.15}`-	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", body))+	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", newPlanBody)) 	require.NoError(t, err)-	require.Equal(t, http.StatusCreated, resp.StatusCode)+	require.Equal(t, http.StatusCreated, resp.StatusCode, "response: %s", resp.Body)  	var got dynamo.PricingItem 	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got)) 	assert.Equal(t, "pricing-uuid-1", got.PricingID)-	assert.Equal(t, "2026-01-01", got.StartDate)-	require.NotNil(t, got.EndDate)-	assert.Equal(t, "2026-12-31", *got.EndDate)-	assert.Equal(t, 0.2873, got.PeakRate)+	assert.Equal(t, "2026-08-01", got.StartDate)+	assert.Nil(t, got.EndDate)+	assert.InDelta(t, 0.35, got.DefaultRate, 1e-9)+	assert.InDelta(t, 0.05, got.FeedInRate, 1e-9)+	require.NotNil(t, got.SavingsReferenceRate)+	assert.InDelta(t, 0.35, *got.SavingsReferenceRate, 1e-9)+	// Windows are stored as entered (Decision 4) — order and all.+	require.Len(t, got.Windows, 2)+	assert.Equal(t, "10:00", got.Windows[0].Start)+	assert.Equal(t, "15:00", got.Windows[0].End)+	assert.True(t, got.Windows[0].Free)+	assert.Nil(t, got.Windows[0].Rate, "a free window carries no rate")+	assert.Equal(t, "01:00", got.Windows[1].Start)+	require.NotNil(t, got.Windows[1].Rate)+	assert.InDelta(t, 0.28, *got.Windows[1].Rate, 1e-9) 	assert.Equal(t, got.CreatedAt, got.UpdatedAt) } +func TestPricing_CreateStoresEndDateAsGiven(t *testing.T) {+	// Decision 5: the wire endDate is the exclusive switch date and is stored+	// verbatim — no ±1 arithmetic anywhere on the write path.+	store := newFakePricingStore()+	h := newPricingTestHandler(store)++	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPost, "/pricing", singleBandBody("2026-01-01", strPtr("2026-08-01"))))+	require.NoError(t, err)+	require.Equal(t, http.StatusCreated, resp.StatusCode, "response: %s", resp.Body)++	var got dynamo.PricingItem+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got))+	require.NotNil(t, got.EndDate)+	assert.Equal(t, "2026-08-01", *got.EndDate)+}+ func TestPricing_CreateOpenEndedPeriod(t *testing.T) { 	store := newFakePricingStore() 	h := newPricingTestHandler(store) -	body := `{"startDate":"2026-01-01","peakRate":0.2873,"feedInRate":0.05,"offPeakSavingsRate":0.15}`-	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", body))+	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPost, "/pricing", singleBandBody("2026-01-01", nil))) 	require.NoError(t, err)-	require.Equal(t, http.StatusCreated, resp.StatusCode)+	require.Equal(t, http.StatusCreated, resp.StatusCode, "response: %s", resp.Body)  	var got dynamo.PricingItem 	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got))@@ -260,42 +331,82 @@ func TestPricing_CreateOpenEndedPeriod(t *testing.T) { }  func TestPricing_CreateValidationErrorsByCode(t *testing.T) {-	// AC 2.3: every validation rule maps to a single machine-parseable-	// error code from the documented set.+	// Requirement 7.2: every band rule maps to a single machine-parseable+	// error code the editor can switch on. 	cases := map[string]struct { 		body string 		code string 	}{-		"inverted_dates": {-			body: `{"startDate":"2026-06-01","endDate":"2026-01-01","peakRate":0.1,"feedInRate":0.05,"offPeakSavingsRate":0.05}`,+		"inverted_dates end before start": {+			body: `{"startDate":"2026-06-01","endDate":"2026-01-01","defaultRate":0.3,"windows":[],"feedInRate":0.05}`, 			code: "inverted_dates", 		},-		"rate_precision peak": {-			body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.12345,"feedInRate":0.05,"offPeakSavingsRate":0.05}`,+		"inverted_dates zero-day plan": {+			// Exclusive ends make endDate == startDate a plan that prices no+			// days at all.+			body: `{"startDate":"2026-06-01","endDate":"2026-06-01","defaultRate":0.3,"windows":[],"feedInRate":0.05}`,+			code: "inverted_dates",+		},+		"band_window_invalid malformed time": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"1000","end":"15:00","free":true}],"feedInRate":0.05,"savingsReferenceRate":0.3}`,+			code: "band_window_invalid",+		},+		"band_window_invalid past end of day": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"10:00","end":"25:00","free":true}],"feedInRate":0.05,"savingsReferenceRate":0.3}`,+			code: "band_window_invalid",+		},+		"band_window_invalid inverted window": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"15:00","end":"10:00","free":true}],"feedInRate":0.05,"savingsReferenceRate":0.3}`,+			code: "band_window_invalid",+		},+		"band_overlap": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"10:00","end":"15:00","free":true},{"start":"14:00","end":"16:00","free":false,"rate":0.2}],"feedInRate":0.05,"savingsReferenceRate":0.3}`,+			code: "band_overlap",+		},+		"multiple_free_bands": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"10:00","end":"12:00","free":true},{"start":"13:00","end":"15:00","free":true}],"feedInRate":0.05,"savingsReferenceRate":0.3}`,+			code: "multiple_free_bands",+		},+		"no_rated_band": {+			// AC 1.3 / Q17: a free window spanning the whole day leaves the+			// cost math and the fallback rate undefined.+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"00:00","end":"24:00","free":true}],"feedInRate":0.05,"savingsReferenceRate":0.3}`,+			code: "no_rated_band",+		},+		"savings_rate_missing": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"10:00","end":"15:00","free":true}],"feedInRate":0.05}`,+			code: "savings_rate_missing",+		},+		"rate_precision default": {+			body: `{"startDate":"2026-01-01","defaultRate":0.12345,"windows":[],"feedInRate":0.05}`, 			code: "rate_precision", 		}, 		"rate_precision feedIn": {-			body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.1,"feedInRate":0.054321,"offPeakSavingsRate":0.05}`,+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[],"feedInRate":0.054321}`, 			code: "rate_precision", 		},-		"rate_precision offPeakSavings": {-			body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.1,"feedInRate":0.05,"offPeakSavingsRate":0.123456}`,+		"rate_precision savings reference": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"10:00","end":"15:00","free":true}],"feedInRate":0.05,"savingsReferenceRate":0.123456}`, 			code: "rate_precision", 		},-		"rate_out_of_range peak negative": {-			body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":-0.01,"feedInRate":0.05,"offPeakSavingsRate":0.05}`,+		"rate_precision window rate": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"01:00","end":"06:00","free":false,"rate":0.28125}],"feedInRate":0.05}`,+			code: "rate_precision",+		},+		"rate_out_of_range default negative": {+			body: `{"startDate":"2026-01-01","defaultRate":-0.01,"windows":[],"feedInRate":0.05}`, 			code: "rate_out_of_range", 		},-		"rate_out_of_range peak above cap": {-			body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":10.0001,"feedInRate":0.05,"offPeakSavingsRate":0.05}`,+		"rate_out_of_range default above cap": {+			body: `{"startDate":"2026-01-01","defaultRate":10.0001,"windows":[],"feedInRate":0.05}`, 			code: "rate_out_of_range", 		},-		"rate_out_of_range feedIn negative": {-			body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.1,"feedInRate":-0.01,"offPeakSavingsRate":0.05}`,+		"rate_out_of_range window rate above cap": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[{"start":"01:00","end":"06:00","free":false,"rate":10.5}],"feedInRate":0.05}`, 			code: "rate_out_of_range", 		},-		"rate_out_of_range offPeakSavings above cap": {-			body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.1,"feedInRate":0.05,"offPeakSavingsRate":10.5}`,+		"rate_out_of_range feedIn negative": {+			body: `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[],"feedInRate":-0.01}`, 			code: "rate_out_of_range", 		}, 	}@@ -307,199 +418,184 @@ func TestPricing_CreateValidationErrorsByCode(t *testing.T) { 			require.NoError(t, err) 			assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 			body := decodeError(t, resp.Body)-			assert.Equal(t, tc.code, body.Error, "AC 2.3: expected error code %q", tc.code)+			assert.Equal(t, tc.code, body.Error, "expected error code %q, body %s", tc.code, resp.Body)+			assert.NotEmpty(t, body.Message, "every validation failure carries a human-readable message") 		}) 	} } -func TestPricing_CreateRejectsOverlap(t *testing.T) {+func TestPricing_CreateRejectsLegacyThreeRateShape(t *testing.T) {+	// AC 7.3 / Q28: encoding/json drops unknown fields, so a legacy body+	// would otherwise decode as a band plan with every rate at zero. The+	// marker has to be detected on the raw JSON keys. 	store := newFakePricingStore()-	end := "2026-06-30"-	store.rows["existing"] = dynamo.PricingItem{-		PricingID: "existing", StartDate: "2026-01-01", EndDate: &end,-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	} 	h := newPricingTestHandler(store) -	body := `{"startDate":"2026-03-01","endDate":"2026-09-30","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}`+	body := `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.2873,"feedInRate":0.05,"offPeakSavingsRate":0.15}` 	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", body)) 	require.NoError(t, err) 	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)-	got := decodeError(t, resp.Body)-	assert.Equal(t, "overlap", got.Error)+	assert.Equal(t, "legacy_shape", decodeError(t, resp.Body).Error)+	assert.Empty(t, store.rows, "a legacy payload must not reach the store") } -func TestPricing_CreateOverlapWithOpenEndedReturnsOpenEndedID(t *testing.T) {-	// AC 3.6 remediation flow needs the offender's id when the offender-	// is the unique open-ended period.+func TestPricing_UpdateRejectsLegacyThreeRateShape(t *testing.T) { 	store := newFakePricingStore()-	store.rows["open-id"] = dynamo.PricingItem{-		PricingID: "open-id", StartDate: "2026-01-01",-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}-	openID := "open-id"-	store.openEndedID = &openID+	store.rows["p-1"] = bandPlanRow("p-1", "2026-01-01", strPtr("2026-12-31"), 0.25) 	h := newPricingTestHandler(store) -	body := `{"startDate":"2026-06-01","endDate":"2026-12-31","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}`-	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", body))+	body := `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}`+	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPut, "/pricing/p-1", body)) 	require.NoError(t, err) 	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+	assert.Equal(t, "legacy_shape", decodeError(t, resp.Body).Error)+} -	var body409 struct {-		Error       string `json:"error"`-		Message     string `json:"message"`-		OpenEndedID string `json:"openEndedId"`-	}-	require.NoError(t, json.Unmarshal([]byte(resp.Body), &body409))-	assert.Equal(t, "overlap", body409.Error)-	assert.Equal(t, "open-id", body409.OpenEndedID,-		"editor needs the offender's id to surface the one-tap remediation")+func TestPricing_CreateRejectsOverlapNamingConflictingPlan(t *testing.T) {+	// AC 2.5: the response identifies the plan that would price the same day.+	store := newFakePricingStore()+	store.rows["existing"] = bandPlanRow("existing", "2026-01-01", strPtr("2026-07-01"), 0.25)+	h := newPricingTestHandler(store)++	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPost, "/pricing", singleBandBody("2026-03-01", strPtr("2026-10-01"))))+	require.NoError(t, err)+	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+	got := decodeError(t, resp.Body)+	assert.Equal(t, "overlap", got.Error)+	assert.Equal(t, "existing", got.ConflictingPricingID) } -func TestPricing_UpdateClosedRowToOpenEndedRejectedAsSecondOpenEnded(t *testing.T) {-	// AC 1.9 surfaces in isolation on the update path: there is already-	// an open-ended period, and we attempt to convert a non-overlapping-	// closed row to open-ended. Overlap excludes the row under update-	// (Decision 17), so the second_open_ended check fires alone.+func TestPricing_CreateAdjacentSwitchDateAccepted(t *testing.T) {+	// AC 2.2: the predecessor's exclusive endDate equals the successor's+	// startDate, so the two ranges abut without overlapping. 	store := newFakePricingStore()-	end := "2026-12-31"-	store.rows["closed"] = dynamo.PricingItem{-		PricingID: "closed", StartDate: "2026-01-01", EndDate: &end,-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}-	store.rows["open-1"] = dynamo.PricingItem{-		PricingID: "open-1", StartDate: "2030-01-01",-		PeakRate: 0.3, FeedInRate: 0.05, OffPeakSavingsRate: 0.1,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}-	openID := "open-1"+	store.rows["existing"] = bandPlanRow("existing", "2026-01-01", strPtr("2026-08-01"), 0.25)+	h := newPricingTestHandler(store)++	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", newPlanBody))+	require.NoError(t, err)+	assert.Equal(t, http.StatusCreated, resp.StatusCode, "response: %s", resp.Body)+}++func TestPricing_CreateOverlapWithOpenEndedReturnsOpenEndedID(t *testing.T) {+	// AC 6.5 remediation flow needs the offender's id when the offender is+	// the unique open-ended period.+	store := newFakePricingStore()+	store.rows["open-id"] = bandPlanRow("open-id", "2026-01-01", nil, 0.25)+	openID := "open-id" 	store.openEndedID = &openID 	h := newPricingTestHandler(store) -	// Update "closed" to drop its endDate. Overlap excludes "closed"-	// itself; the resulting candidate's [2026-01-01, ∞) range collides-	// only on second_open_ended because "open-1" is at 2030-01-01 and-	// overlap would fire (the candidate range still hits open-1's tail).-	// To make second_open_ended fire in isolation, the candidate range-	// must not collide with "open-1" at all. That means setting the-	// candidate start AFTER open-1's start and well before its-	// open-ended tail — impossible while open-1 exists. Demonstrate the-	// expected coupling instead: deleting open-1 first means the same-	// update succeeds.-	body := `{"startDate":"2026-01-01","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}`-	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPut, "/pricing/closed", body))+	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPost, "/pricing", singleBandBody("2026-06-01", strPtr("2026-12-31")))) 	require.NoError(t, err) 	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)-	// Overlap fires first because open-1's open-ended tail intersects-	// the new candidate's range. AC 1.10 documents that ordering.-	assert.Equal(t, "overlap", decodeError(t, resp.Body).Error)--	// Now drop the conflict: delete open-1 from the store, retry.-	delete(store.rows, "open-1")-	store.openEndedID = nil-	resp2, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPut, "/pricing/closed", body))-	require.NoError(t, err)-	assert.Equal(t, http.StatusOK, resp2.StatusCode,-		"with the existing open-ended row gone, the same update succeeds")++	got := decodeError(t, resp.Body)+	assert.Equal(t, "overlap", got.Error)+	assert.Equal(t, "open-id", got.OpenEndedID,+		"editor needs the offender's id to surface the one-tap remediation")+	assert.Equal(t, "open-id", got.ConflictingPricingID) }  func TestPricing_CreateValidationChainOrder(t *testing.T) {-	// AC 1.10: when multiple rules fail, return the FIRST in order-	// 1.6 → 1.7 → 1.4 → 1.8 → 1.9.+	// Ordering is inverted_dates → overlap → the remaining band rules →+	// second_open_ended, carried over from the flat-rate chain. 	store := newFakePricingStore()-	// Existing closed period to make overlap possible.-	end := "2026-06-30"-	store.rows["existing"] = dynamo.PricingItem{-		PricingID: "existing", StartDate: "2026-01-01", EndDate: &end,-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}+	store.rows["existing"] = bandPlanRow("existing", "2026-01-01", strPtr("2026-07-01"), 0.25) 	h := newPricingTestHandler(store) -	// Inverted dates + overlap + precision + range + (would-be) second-	// open-ended all violated simultaneously. Expect inverted_dates first.-	body := `{"startDate":"2026-06-30","endDate":"2026-01-15","peakRate":12.34567,"feedInRate":-0.01,"offPeakSavingsRate":0.05}`+	// Inverted dates + overlap + band overlap + precision all violated.+	body := `{"startDate":"2026-06-30","endDate":"2026-01-15","defaultRate":12.34567,` ++		`"windows":[{"start":"10:00","end":"15:00","free":true},{"start":"14:00","end":"16:00","free":false,"rate":0.2}],` ++		`"feedInRate":-0.01,"savingsReferenceRate":0.3}` 	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", body)) 	require.NoError(t, err) 	require.Equal(t, http.StatusBadRequest, resp.StatusCode) 	assert.Equal(t, "inverted_dates", decodeError(t, resp.Body).Error,-		"AC 1.10: inverted_dates is checked first")+		"inverted_dates is checked first") -	// Without inversion: overlap + precision both fire — expect overlap.-	body = `{"startDate":"2026-03-01","endDate":"2026-08-31","peakRate":0.12345,"feedInRate":0.05,"offPeakSavingsRate":0.05}`+	// Without inversion: overlap + band_overlap both fire — expect the+	// date-range overlap.+	body = `{"startDate":"2026-03-01","endDate":"2026-09-01","defaultRate":0.3,` ++		`"windows":[{"start":"10:00","end":"15:00","free":true},{"start":"14:00","end":"16:00","free":false,"rate":0.2}],` ++		`"feedInRate":0.05,"savingsReferenceRate":0.3}` 	resp, err = h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", body)) 	require.NoError(t, err) 	assert.Equal(t, "overlap", decodeError(t, resp.Body).Error,-		"AC 1.10: overlap precedes rate_precision")+		"date-range overlap precedes the band rules") }  func TestPricing_UpdateExistingPeriod(t *testing.T) { 	store := newFakePricingStore()-	end := "2026-12-31"-	store.rows["p-1"] = dynamo.PricingItem{-		PricingID: "p-1", StartDate: "2026-01-01", EndDate: &end,-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-20T10:00:00Z", UpdatedAt: "2026-05-20T10:00:00Z",-	}+	store.rows["p-1"] = bandPlanRow("p-1", "2026-01-01", strPtr("2027-01-01"), 0.25)+	created := store.rows["p-1"]+	created.CreatedAt = "2026-05-20T10:00:00Z"+	created.UpdatedAt = "2026-05-20T10:00:00Z"+	store.rows["p-1"] = created 	h := newPricingTestHandler(store) -	body := `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}`-	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPut, "/pricing/p-1", body))+	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPut, "/pricing/p-1", singleBandBody("2026-01-01", strPtr("2027-01-01")))) 	require.NoError(t, err)-	require.Equal(t, http.StatusOK, resp.StatusCode)+	require.Equal(t, http.StatusOK, resp.StatusCode, "response: %s", resp.Body)  	var got dynamo.PricingItem 	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got)) 	assert.Equal(t, "p-1", got.PricingID)-	assert.Equal(t, 0.3, got.PeakRate)+	assert.InDelta(t, 0.3, got.DefaultRate, 1e-9) 	assert.Equal(t, "2026-05-20T10:00:00Z", got.CreatedAt, 		"createdAt must be preserved across update")-	assert.NotEqual(t, "2026-05-20T10:00:00Z", got.UpdatedAt,-		"updatedAt must bump on every PUT") 	assert.Equal(t, "2026-05-23T10:00:00Z", got.UpdatedAt, 		"updatedAt must be the handler's nowFunc value") }  func TestPricing_UpdateExcludesSelfFromOverlapCheck(t *testing.T) {-	// AC 1.7 / Decision 17: the period being updated is excluded from-	// the overlap check. 	store := newFakePricingStore()-	end := "2026-12-31"-	store.rows["p-1"] = dynamo.PricingItem{-		PricingID: "p-1", StartDate: "2026-01-01", EndDate: &end,-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}+	store.rows["p-1"] = bandPlanRow("p-1", "2026-01-01", strPtr("2027-01-01"), 0.25) 	h := newPricingTestHandler(store) -	// Same date range — should succeed (rate-only edit).-	body := `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}`-	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPut, "/pricing/p-1", body))+	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPut, "/pricing/p-1", singleBandBody("2026-01-01", strPtr("2027-01-01")))) 	require.NoError(t, err) 	assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestPricing_UpdateEndsOpenEndedPlanWithoutSuccessor(t *testing.T) {+	// AC 2.4: giving the open-ended plan an end date is allowed on its own;+	// the days after it are simply unpriced until a successor exists.+	store := newFakePricingStore()+	store.rows["open-id"] = bandPlanRow("open-id", "2026-01-01", nil, 0.25)+	openID := "open-id"+	store.openEndedID = &openID+	h := newPricingTestHandler(store)++	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPut, "/pricing/open-id", singleBandBody("2026-01-01", strPtr("2026-08-01"))))+	require.NoError(t, err)+	require.Equal(t, http.StatusOK, resp.StatusCode, "response: %s", resp.Body)++	var got dynamo.PricingItem+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got))+	require.NotNil(t, got.EndDate)+	assert.Equal(t, "2026-08-01", *got.EndDate)+	assert.Nil(t, store.openEndedID, "the plan is no longer open-ended")+}+ func TestPricing_UpdateUnknownIdReturns404(t *testing.T) { 	store := newFakePricingStore() 	h := newPricingTestHandler(store) -	body := `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}`-	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPut, "/pricing/missing", body))+	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPut, "/pricing/missing", singleBandBody("2026-01-01", strPtr("2027-01-01")))) 	require.NoError(t, err) 	assert.Equal(t, http.StatusNotFound, resp.StatusCode) }  func TestPricing_DeleteExistingPeriod(t *testing.T) { 	store := newFakePricingStore()-	end := "2026-12-31"-	store.rows["p-1"] = dynamo.PricingItem{-		PricingID: "p-1", StartDate: "2026-01-01", EndDate: &end,-		CreatedAt: "2026-05-23T10:00:00Z",-	}+	store.rows["p-1"] = bandPlanRow("p-1", "2026-01-01", strPtr("2027-01-01"), 0.25) 	h := newPricingTestHandler(store)  	resp, err := h.Handle(context.Background(), makeRequest(http.MethodDelete, "/pricing/p-1", "Bearer "+testToken))@@ -509,7 +605,6 @@ func TestPricing_DeleteExistingPeriod(t *testing.T) { }  func TestPricing_DeleteUnknownIdReturns404(t *testing.T) {-	// AC 2.4 / Decision 11: delete returns 404 on unknown id. 	store := newFakePricingStore() 	h := newPricingTestHandler(store) @@ -518,18 +613,17 @@ func TestPricing_DeleteUnknownIdReturns404(t *testing.T) { 	assert.Equal(t, http.StatusNotFound, resp.StatusCode) } -func TestPricing_ReplaceOpenEndedHappyPath(t *testing.T) {+func TestPricing_ReplaceOpenEndedSameDaySuccession(t *testing.T) {+	// AC 2.2: the closing row's exclusive endDate is the successor's start+	// date — the same literal string on both rows, so the switch day is+	// priced by the successor. 	store := newFakePricingStore()-	store.rows["open-id"] = dynamo.PricingItem{-		PricingID: "open-id", StartDate: "2026-01-01",-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}+	store.rows["open-id"] = bandPlanRow("open-id", "2026-01-01", nil, 0.25) 	openID := "open-id" 	store.openEndedID = &openID 	h := newPricingTestHandler(store) -	body := `{"closingPricingId":"open-id","newPeriod":{"startDate":"2026-06-01","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}}`+	body := `{"closingPricingId":"open-id","newPeriod":` + newPlanBody + `}` 	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing/replace-open-ended", body)) 	require.NoError(t, err) 	require.Equal(t, http.StatusOK, resp.StatusCode, "response: %s", resp.Body)@@ -539,20 +633,73 @@ func TestPricing_ReplaceOpenEndedHappyPath(t *testing.T) { 	} 	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got)) 	require.Len(t, got.Pricing, 2)-	// Closing row should have been capped to startDate − 1 day. 	require.NotNil(t, got.Pricing[0].EndDate) 	assert.Equal(t, "open-id", got.Pricing[0].PricingID)-	assert.Equal(t, "2026-05-31", *got.Pricing[0].EndDate,-		"closing-row endDate should equal newPeriod.startDate − 1 day")-	// New row open-ended.+	assert.Equal(t, "2026-08-01", *got.Pricing[0].EndDate,+		"closing-row endDate equals newPeriod.startDate")+	assert.Equal(t, "2026-08-01", got.Pricing[1].StartDate) 	assert.Nil(t, got.Pricing[1].EndDate)+	require.Len(t, got.Pricing[1].Windows, 2, "successor keeps its band windows")+}++func TestPricing_ReplaceOpenEndedAcceptsFutureDatedSuccessor(t *testing.T) {+	// AC 2.3: the successor is entered ahead of its start date, which is what+	// makes the switch happen automatically.+	store := newFakePricingStore()+	store.rows["open-id"] = bandPlanRow("open-id", "2026-01-01", nil, 0.25)+	openID := "open-id"+	store.openEndedID = &openID+	h := newPricingTestHandler(store)++	// nowFunc is 2026-05-23; the successor starts more than two months later.+	body := `{"closingPricingId":"open-id","newPeriod":` + newPlanBody + `}`+	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing/replace-open-ended", body))+	require.NoError(t, err)+	require.Equal(t, http.StatusOK, resp.StatusCode, "a future start date is accepted: %s", resp.Body)++	stored := store.rows["open-id"]+	require.NotNil(t, stored.EndDate)+	assert.Equal(t, "2026-08-01", *stored.EndDate)+}++func TestPricing_ReplaceOpenEndedRejectsLegacyNewPeriod(t *testing.T) {+	store := newFakePricingStore()+	store.rows["open-id"] = bandPlanRow("open-id", "2026-01-01", nil, 0.25)+	openID := "open-id"+	store.openEndedID = &openID+	h := newPricingTestHandler(store)++	body := `{"closingPricingId":"open-id","newPeriod":{"startDate":"2026-06-01","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}}`+	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing/replace-open-ended", body))+	require.NoError(t, err)+	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+	assert.Equal(t, "legacy_shape", decodeError(t, resp.Body).Error)+}++func TestPricing_ReplaceOpenEndedMapsLegacyClosingRowTo400(t *testing.T) {+	// Q32: the store refuses to patch a closing row that is still the legacy+	// three-rate shape — a partial UpdateItem would leave a legacy-detected+	// row carrying an exclusive end date, double-shifted by the read transform+	// and the migration.+	store := newFakePricingStore()+	store.rows["open-id"] = bandPlanRow("open-id", "2026-01-01", nil, 0.25)+	openID := "open-id"+	store.openEndedID = &openID+	store.replaceErr = dynamo.ErrPricingLegacyShape+	h := newPricingTestHandler(store)++	body := `{"closingPricingId":"open-id","newPeriod":` + newPlanBody + `}`+	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing/replace-open-ended", body))+	require.NoError(t, err)+	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+	assert.Equal(t, "legacy_shape", decodeError(t, resp.Body).Error) }  func TestPricing_ReplaceOpenEndedRejectsUnknownClosingID(t *testing.T) { 	store := newFakePricingStore() 	h := newPricingTestHandler(store) -	body := `{"closingPricingId":"missing","newPeriod":{"startDate":"2026-06-01","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}}`+	body := `{"closingPricingId":"missing","newPeriod":` + newPlanBody + `}` 	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing/replace-open-ended", body)) 	require.NoError(t, err) 	assert.Equal(t, http.StatusNotFound, resp.StatusCode)@@ -560,17 +707,13 @@ func TestPricing_ReplaceOpenEndedRejectsUnknownClosingID(t *testing.T) {  func TestPricing_ReplaceOpenEndedMapsConcurrentWriteTo409(t *testing.T) { 	store := newFakePricingStore()-	store.rows["open-id"] = dynamo.PricingItem{-		PricingID: "open-id", StartDate: "2026-01-01",-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}+	store.rows["open-id"] = bandPlanRow("open-id", "2026-01-01", nil, 0.25) 	openID := "open-id" 	store.openEndedID = &openID 	store.replaceErr = dynamo.ErrPricingConcurrentWrite 	h := newPricingTestHandler(store) -	body := `{"closingPricingId":"open-id","newPeriod":{"startDate":"2026-06-01","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}}`+	body := `{"closingPricingId":"open-id","newPeriod":` + newPlanBody + `}` 	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing/replace-open-ended", body)) 	require.NoError(t, err) 	assert.Equal(t, http.StatusConflict, resp.StatusCode)@@ -579,17 +722,13 @@ func TestPricing_ReplaceOpenEndedMapsConcurrentWriteTo409(t *testing.T) {  func TestPricing_ReplaceOpenEndedMapsUUIDCollisionTo500(t *testing.T) { 	store := newFakePricingStore()-	store.rows["open-id"] = dynamo.PricingItem{-		PricingID: "open-id", StartDate: "2026-01-01",-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}+	store.rows["open-id"] = bandPlanRow("open-id", "2026-01-01", nil, 0.25) 	openID := "open-id" 	store.openEndedID = &openID 	store.replaceErr = dynamo.ErrPricingUUIDCollision 	h := newPricingTestHandler(store) -	body := `{"closingPricingId":"open-id","newPeriod":{"startDate":"2026-06-01","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}}`+	body := `{"closingPricingId":"open-id","newPeriod":` + newPlanBody + `}` 	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing/replace-open-ended", body)) 	require.NoError(t, err) 	assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)@@ -597,15 +736,12 @@ func TestPricing_ReplaceOpenEndedMapsUUIDCollisionTo500(t *testing.T) { }  func TestPricing_CreateMapsTransactionalConcurrentWriteTo409(t *testing.T) {-	// Open-ended period creation goes through the transactional path;-	// dynamo.ErrPricingConcurrentWrite from the store must surface as-	// HTTP 409 concurrent_open_ended_write. 	store := newFakePricingStore() 	store.putErr = dynamo.ErrPricingConcurrentWrite 	h := newPricingTestHandler(store) -	body := `{"startDate":"2026-01-01","peakRate":0.3,"feedInRate":0.05,"offPeakSavingsRate":0.1}`-	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", body))+	resp, err := h.Handle(context.Background(),+		makeJSONRequest(http.MethodPost, "/pricing", singleBandBody("2026-01-01", nil))) 	require.NoError(t, err) 	assert.Equal(t, http.StatusConflict, resp.StatusCode) 	assert.Equal(t, "concurrent_open_ended_write", decodeError(t, resp.Body).Error)@@ -620,6 +756,26 @@ func TestPricing_MalformedBodyReturns400(t *testing.T) { 	assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } +func TestPricing_BodyOverCapReturns400(t *testing.T) {+	// The 4 KB cap is retained: a plan with a pathological number of windows+	// is rejected before it reaches the validator.+	store := newFakePricingStore()+	h := newPricingTestHandler(store)++	windows := make([]string, 0, 200)+	for i := range 200 {+		windows = append(windows, fmt.Sprintf(`{"start":"%02d:00","end":"%02d:30","free":false,"rate":0.28}`, i%24, i%24))+	}+	body := `{"startDate":"2026-01-01","defaultRate":0.3,"windows":[` ++		strings.Join(windows, ",") + `],"feedInRate":0.05}`+	require.Greater(t, len(body), pricingBodyMaxBytes)++	resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", body))+	require.NoError(t, err)+	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+	assert.Empty(t, store.rows)+}+ func TestPricing_StoreFailureMapsTo500(t *testing.T) { 	store := newFakePricingStore() 	store.listErr = errors.New("dynamo down")
internal/api/pricing_vectors_test.go Added +250 / -0
diff --git a/internal/api/pricing_vectors_test.go b/internal/api/pricing_vectors_test.gonew file mode 100644index 0000000..eced83f--- /dev/null+++ b/internal/api/pricing_vectors_test.go@@ -0,0 +1,250 @@+package api++import (+	"encoding/json"+	"os"+	"path/filepath"+	"testing"++	"github.com/ArjenSchwarz/flux/internal/plan"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// The two vector files in testdata are shared with the FluxCore test suite+// (the note_lengths.json pattern). Segmentation and cost resolution exist in+// both Go and Swift; these vectors are what stop the two implementations from+// drifting, pinning AC 3.1–3.6 to identical numbers on both sides.+//+// The cost vectors are also the AC 5.2 proof: the tier-2 rows are the+// pre-band DayCosts formula, and cmd/migrate-pricing computes its golden+// values with the same plan.DayCosts helper these tests exercise.++// vectorWindow mirrors the plan wire shape's window entry.+type vectorWindow struct {+	Start string   `json:"start"`+	End   string   `json:"end"`+	Free  bool     `json:"free"`+	Rate  *float64 `json:"rate"`+}++// vectorPlan mirrors the plan wire shape.+type vectorPlan struct {+	DefaultRate          float64        `json:"defaultRate"`+	Windows              []vectorWindow `json:"windows"`+	FeedInRate           float64        `json:"feedInRate"`+	SavingsReferenceRate *float64       `json:"savingsReferenceRate"`+}++func (v vectorPlan) toPlan() plan.Plan {+	windows := make([]plan.Window, len(v.Windows))+	for i, w := range v.Windows {+		windows[i] = plan.Window{Start: w.Start, End: w.End, Free: w.Free}+		if w.Rate != nil {+			windows[i].Rate = *w.Rate+		}+	}+	return plan.Plan{+		StartDate:      "2026-01-01",+		DefaultRate:    v.DefaultRate,+		Windows:        windows,+		FeedInRate:     v.FeedInRate,+		SavingsRefRate: v.SavingsReferenceRate,+	}+}++type vectorSegment struct {+	Start string  `json:"start"`+	End   string  `json:"end"`+	Free  bool    `json:"free"`+	Rate  float64 `json:"rate"`+}++type segmentVector struct {+	Name        string          `json:"name"`+	Description string          `json:"description"`+	Plan        vectorPlan      `json:"plan"`+	Segments    []vectorSegment `json:"segments"`+}++type vectorOffpeak struct {+	GridImportKwh float64 `json:"gridImportKwh"`+	WindowStart   *string `json:"windowStart"`+	WindowEnd     *string `json:"windowEnd"`+	IntegratedAt  *string `json:"integratedAt"`+	SampleCount   int     `json:"sampleCount"`+}++type vectorBandImport struct {+	Start string  `json:"start"`+	End   string  `json:"end"`+	Kwh   float64 `json:"kwh"`+}++type vectorDay struct {+	EInput            *float64           `json:"eInput"`+	EOutput           *float64           `json:"eOutput"`+	PeakGridImportKwh *float64           `json:"peakGridImportKwh"`+	Offpeak           *vectorOffpeak     `json:"offpeak"`+	BandImports       []vectorBandImport `json:"bandImports"`+}++func (v vectorDay) toDayEnergy() plan.DayEnergy {+	out := plan.DayEnergy{+		EInput:            v.EInput,+		EOutput:           v.EOutput,+		PeakGridImportKwh: v.PeakGridImportKwh,+	}+	if v.Offpeak != nil {+		row := plan.OffpeakRow{+			GridImportKwh: v.Offpeak.GridImportKwh,+			SampleCount:   v.Offpeak.SampleCount,+		}+		if v.Offpeak.WindowStart != nil {+			row.WindowStart = *v.Offpeak.WindowStart+		}+		if v.Offpeak.WindowEnd != nil {+			row.WindowEnd = *v.Offpeak.WindowEnd+		}+		if v.Offpeak.IntegratedAt != nil {+			row.IntegratedAt = *v.Offpeak.IntegratedAt+		}+		out.Offpeak = &row+	}+	if v.BandImports != nil {+		out.BandImports = make([]plan.BandImport, len(v.BandImports))+		for i, b := range v.BandImports {+			out.BandImports[i] = plan.BandImport{Start: b.Start, End: b.End, Kwh: b.Kwh}+		}+	}+	return out+}++type vectorCosts struct {+	Tier         int     `json:"tier"`+	ImportCost   float64 `json:"importCost"`+	FeedInIncome float64 `json:"feedInIncome"`+	Net          float64 `json:"net"`+	Savings      float64 `json:"savings"`+}++type costVector struct {+	Name        string      `json:"name"`+	Description string      `json:"description"`+	Plan        vectorPlan  `json:"plan"`+	Day         vectorDay   `json:"day"`+	Expected    vectorCosts `json:"expected"`+}++func loadVectors[T any](t *testing.T, file string) []T {+	t.Helper()+	data, err := os.ReadFile(filepath.Join("testdata", file))+	require.NoError(t, err, "read %s", file)+	var out []T+	require.NoError(t, json.Unmarshal(data, &out), "parse %s", file)+	require.NotEmpty(t, out, "%s must contain vectors", file)+	return out+}++// TestPricingSegmentVectors pins plan.Segments to the shared vectors the+// FluxCore segmentation helper is also tested against.+func TestPricingSegmentVectors(t *testing.T) {+	t.Parallel()+	for _, vec := range loadVectors[segmentVector](t, "pricing_segments.json") {+		t.Run(vec.Name, func(t *testing.T) {+			t.Parallel()+			want := make([]plan.Segment, len(vec.Segments))+			for i, s := range vec.Segments {+				want[i] = plan.Segment{Start: s.Start, End: s.End, Free: s.Free, Rate: s.Rate}+			}+			assert.Equal(t, want, plan.Segments(vec.Plan.toPlan()), vec.Description)+		})+	}+}++// TestPricingCostVectors pins plan.DayCosts — resolution tier and all four+// figures — to the shared vectors.+func TestPricingCostVectors(t *testing.T) {+	t.Parallel()+	for _, vec := range loadVectors[costVector](t, "pricing_costs.json") {+		t.Run(vec.Name, func(t *testing.T) {+			t.Parallel()+			got, tier := plan.DayCosts(vec.Plan.toPlan(), vec.Day.toDayEnergy())+			assert.Equal(t, plan.Tier(vec.Expected.Tier), tier, vec.Description)+			assert.InDelta(t, vec.Expected.ImportCost, got.ImportCost, 1e-9, "importCost")+			assert.InDelta(t, vec.Expected.FeedInIncome, got.FeedInIncome, 1e-9, "feedInIncome")+			assert.InDelta(t, vec.Expected.Net, got.Net, 1e-9, "net")+			assert.InDelta(t, vec.Expected.Savings, got.Savings, 1e-9, "savings")+		})+	}+}++// TestPricingCostVectorsCoverEveryTier guards the vector file itself: the+// design requires every resolution path and every tier-2 input combination to+// be represented, because these vectors are what the FluxCore implementation+// is held to.+func TestPricingCostVectorsCoverEveryTier(t *testing.T) {+	t.Parallel()+	vectors := loadVectors[costVector](t, "pricing_costs.json")++	byTier := map[int]int{}+	combos := map[string]bool{}+	for _, vec := range vectors {+		byTier[vec.Expected.Tier]+++		if vec.Expected.Tier != 2 {+			continue+		}+		key := "offpeak:" + boolLabel(vec.Day.Offpeak != nil) ++			" peak:" + boolLabel(vec.Day.PeakGridImportKwh != nil)+		combos[key] = true+	}++	for tier := 1; tier <= 3; tier++ {+		assert.NotZero(t, byTier[tier], "tier %d must be covered", tier)+	}+	for _, offpeak := range []bool{true, false} {+		for _, peak := range []bool{true, false} {+			key := "offpeak:" + boolLabel(offpeak) + " peak:" + boolLabel(peak)+			assert.True(t, combos[key], "tier-2 combination %s must be covered", key)+		}+	}+}++func boolLabel(v bool) string {+	if v {+		return "present"+	}+	return "absent"+}++// TestSingleRatePlansNeverReachFallback pins the property that makes AC 5.2+// hold without backfilling history: for a plan whose rated segments share one+// rate — every migrated legacy plan — tier 2 always resolves, so no historical+// day can degrade to the fallback.+func TestSingleRatePlansNeverReachFallback(t *testing.T) {+	t.Parallel()+	savings := 0.35+	migrated := plan.Plan{+		StartDate:      "2026-01-01",+		DefaultRate:    0.35,+		Windows:        []plan.Window{{Start: "11:00", End: "14:00", Free: true}},+		FeedInRate:     0.05,+		SavingsRefRate: &savings,+	}+	eInput := 20.0+	days := map[string]plan.DayEnergy{+		"nothing recorded":     {},+		"eInput only":          {EInput: &eInput},+		"unusable offpeak row": {EInput: &eInput, Offpeak: &plan.OffpeakRow{IntegratedAt: "2026-04-12T04:00:00Z"}},+		"mismatched split": {EInput: &eInput, BandImports: []plan.BandImport{+			{Start: "07:00", End: "09:00", Kwh: 1},+		}},+	}+	for name, day := range days {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			_, tier := plan.DayCosts(migrated, day)+			assert.NotEqual(t, plan.TierFallback, tier)+		})+	}+}
internal/api/pricing.go Modified +17 / -13
diff --git a/internal/api/pricing.go b/internal/api/pricing.goindex e615e31..65c2c3b 100644--- a/internal/api/pricing.go+++ b/internal/api/pricing.go@@ -4,6 +4,7 @@ import ( 	"context"  	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" )  // PricingStore is the api-package-local view of the pricing read+write@@ -19,31 +20,34 @@ type PricingStore interface { 	ReplaceOpenEnded(ctx context.Context, closingID, closingEndDate, updatedAt string, newItem dynamo.PricingItem) error } -// Pricing error codes documented in AC 2.3 and the design's-// TransactionCanceledException → HTTP mapping table.+// Pricing error codes returned in the response envelope's "error" field.+//+// The single-plan band codes are not listed here: the handler emits whatever+// plan.Validate reports, so aliasing them would be a second copy that could+// silently fall out of step with the rules they name. The full set it can+// forward is plan.CodeBandWindowInvalid, CodeBandOverlap, CodeMultipleFreeBands,+// CodeSavingsRateMissing, CodeNoRatedBand, CodeRatePrecision, and+// CodeRateOutOfRange. CodeInvertedDates is aliased below because the handler+// raises it directly, not only by forwarding. const (-	pricingCodeInvertedDates   = "inverted_dates"+	pricingCodeInvertedDates = plan.CodeInvertedDates+ 	pricingCodeOverlap         = "overlap"-	pricingCodeRatePrecision   = "rate_precision"-	pricingCodeRateOutOfRange  = "rate_out_of_range" 	pricingCodeSecondOpenEnded = "second_open_ended" 	pricingCodeConcurrentWrite = "concurrent_open_ended_write"+	pricingCodeLegacyShape     = "legacy_shape" 	pricingCodeNotFound        = "not_found" 	pricingCodeBadRequest      = "bad_request" 	pricingCodeInternal        = "internal_error" ) -// pricingRateCap is the per-rate upper bound from Decision 12 — 10×-// the highest plausible AU retail tariff, catching order-of-magnitude-// typos without constraining legitimate use.-const pricingRateCap = 10.0- // pricingMaxEndDate is the "open-ended" sentinel value used by the // in-memory overlap check. Lexicographic compare on a YYYY-MM-DD string // keeps the check trivial. const pricingMaxEndDate = "9999-12-31" -// pricingBodyMaxBytes caps inbound JSON on every pricing mutation. A-// maxed-out payload fits comfortably under 512 bytes; the cap leaves-// generous room for whitespace.+// pricingBodyMaxBytes caps inbound JSON on every pricing mutation. The+// incoming time-of-use plan (three rates, two windows) fits in well under+// 512 bytes; the cap leaves generous room for a plan with many bands+// while still bounding a pathological payload. const pricingBodyMaxBytes = 4096
internal/api/response.go Modified +110 / -1
diff --git a/internal/api/response.go b/internal/api/response.goindex af826c2..9b8f8f7 100644--- a/internal/api/response.go+++ b/internal/api/response.go@@ -1,7 +1,13 @@ // Package api implements the Lambda API request handling and business logic. package api -import "github.com/ArjenSchwarz/flux/internal/derivedstats"+import (+	"time"++	"github.com/ArjenSchwarz/flux/internal/derivedstats"+	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan"+)  // StatusResponse is the JSON response for GET /status. type StatusResponse struct {@@ -86,6 +92,99 @@ type OffpeakData struct { 	ProjectedEndSoc     *float64 `json:"projectedEndSoc"` } +// BandImport is one rated band's grid import for a day.+//+// Start and End are the band's "HH:MM" boundaries in Sydney local time (End+// may be "24:00"), snapshotted alongside the value so a later plan-window+// edit shows up to the client as a geometry mismatch instead of silently+// mispricing the day (Q23).+//+// Only rated bands appear: the free band's import lives in+// offpeakGridImportKwh, which owns that quantity exclusively (Q31).+type BandImport struct {+	Start string  `json:"start"`+	End   string  `json:"end"`+	Kwh   float64 `json:"kwh"`+}++// OffpeakSource describes the flux-offpeak row that OffpeakGridImportKwh came+// from. Clients need it to decide whether that value can price a plan's free+// band: the geometry so a plan-window edit shows up as a mismatch rather than+// silently mispricing the day (Q23/Q16), and the integration provenance so a+// sparse-complete row (integrated, no samples) is recognised as a zero-delta+// artifact rather than a measured zero.+//+// The fields are flattened into DaySummary and DayEnergy, alongside the+// off-peak values they describe. All are absent when the day has no off-peak+// split; the window is additionally absent on pre-feature rows, which can only+// have been integrated under 11:00–14:00.+type OffpeakSource struct {+	OffpeakWindowStart  string `json:"offpeakWindowStart,omitempty"`+	OffpeakWindowEnd    string `json:"offpeakWindowEnd,omitempty"`+	OffpeakIntegratedAt string `json:"offpeakIntegratedAt,omitempty"`+	OffpeakSampleCount  *int   `json:"offpeakSampleCount,omitempty"`+}++// offpeakSourceFrom describes the row a day's off-peak split came from.+//+// Today's split is live-integrated from readings over the resolved window+// rather than read off the row, so it carries that window's geometry and no+// row provenance — it is a measurement by construction.+func offpeakSourceFrom(op dynamo.OffpeakItem, isToday bool, window *offpeakWindow) OffpeakSource {+	if isToday && op.Status != dynamo.OffpeakStatusComplete {+		start, end := hhmmBounds(window)+		return OffpeakSource{OffpeakWindowStart: start, OffpeakWindowEnd: end}+	}+	start, end := op.Geometry()+	count := op.IntegrationSampleCount+	return OffpeakSource{+		OffpeakWindowStart:  start,+		OffpeakWindowEnd:    end,+		OffpeakIntegratedAt: op.IntegratedAt,+		OffpeakSampleCount:  &count,+	}+}++// bandImportsFromAttr converts the stored per-band split to the wire shape.+// Returns nil for an absent split so the field is omitted rather than sent as+// an empty array, which a client could misread as "zero import in every band".+func bandImportsFromAttr(stored []dynamo.BandImportAttr) []BandImport {+	if len(stored) == 0 {+		return nil+	}+	out := make([]BandImport, len(stored))+	for i, b := range stored {+		out[i] = BandImport{Start: b.Start, End: b.End, Kwh: b.Kwh}+	}+	return out+}++// bandImportsFor resolves one day's rated-band split for a read endpoint.+//+// Today's is integrated live from readings; a past day's is served from the+// split captured at day close, which outlives the 30-day readings TTL. AC 3.4+// requires /day and /history to report the identical split for the same day,+// so both call this rather than each deciding today-vs-stored for itself.+//+// A nil result means "no split available", which the cost helper treats as+// unavailable rather than as zero import in every band.+func bandImportsFor(plans []plan.Plan, date string, isToday bool,+	readings []dynamo.ReadingItem, now time.Time, stored []dynamo.BandImportAttr,+) []BandImport {+	if !isToday {+		return bandImportsFromAttr(stored)+	}+	p, priced := plan.PlanFor(plans, date)+	if !priced {+		return nil+	}+	bands, ok := liveBandImports(readings, now, p)+	if !ok {+		return nil+	}+	return bands+}+ // TodayEnergy contains cumulative energy totals for the current day. type TodayEnergy struct { 	Epv        float64 `json:"epv"`@@ -119,6 +218,8 @@ type DayEnergy struct { 	OffpeakGridImportKwh *float64                  `json:"offpeakGridImportKwh,omitempty"` 	OffpeakGridExportKwh *float64                  `json:"offpeakGridExportKwh,omitempty"` 	PeakGridImportKwh    *float64                  `json:"peakGridImportKwh,omitempty"`+	BandImports          []BandImport              `json:"bandImports,omitempty"`+	OffpeakSource                                  // flattened: offpeakWindowStart/End, offpeakIntegratedAt, offpeakSampleCount 	DailyUsage           *derivedstats.DailyUsage  `json:"dailyUsage,omitempty"` 	SocLow               *float64                  `json:"socLow,omitempty"` 	SocLowTime           *string                   `json:"socLowTime,omitempty"`@@ -189,4 +290,12 @@ type DaySummary struct { 	OffpeakGridImportKwh *float64 `json:"offpeakGridImportKwh,omitempty"` 	OffpeakGridExportKwh *float64 `json:"offpeakGridExportKwh,omitempty"` 	PeakGridImportKwh    *float64 `json:"peakGridImportKwh,omitempty"`++	// BandImports is the day's rated-band import split, absent when the day+	// is unpriced or its split is unavailable (AC 3.6).+	BandImports []BandImport `json:"bandImports,omitempty"`++	// OffpeakSource describes the row OffpeakGridImportKwh came from, so the+	// client can tell whether it can price this plan's free band.+	OffpeakSource }
internal/api/simulationpresets_test.go Modified +2 / -2
diff --git a/internal/api/simulationpresets_test.go b/internal/api/simulationpresets_test.goindex 985d921..aea21ef 100644--- a/internal/api/simulationpresets_test.go+++ b/internal/api/simulationpresets_test.go@@ -73,7 +73,7 @@ func (s *fakeSimulationPresetStore) DeletePreset(_ context.Context, id string) e // newPresetsTestHandler wires a fake preset store with a fixed clock and a // deterministic id generator so create/list/PUT/DELETE assertions are exact. func newPresetsTestHandler(store *fakeSimulationPresetStore) *Handler {-	h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(&mockReader{}, nil, testSerial, testToken) 	h.SetSimulationPresetStore(store) 	fixed := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC) 	h.nowFunc = func() time.Time { return fixed }@@ -244,7 +244,7 @@ func TestHandlePresetValidationBoundaryAccepted(t *testing.T) {  func TestHandleCreatePreset_CapReturns409(t *testing.T) { 	store := newFakeSimulationPresetStore()-	for i := 0; i < 20; i++ {+	for i := range 20 { 		store.presets = append(store.presets, dynamo.SimulationPresetItem{ 			PresetID:  fmt.Sprintf("p%d", i), 			Label:     fmt.Sprintf("Preset %d", i),
internal/api/socrules_test.go Modified +2 / -2
diff --git a/internal/api/socrules_test.go b/internal/api/socrules_test.goindex 2a4d3f3..55532dc 100644--- a/internal/api/socrules_test.go+++ b/internal/api/socrules_test.go@@ -94,7 +94,7 @@ func (f *fakeFireStateCleaner) DeleteFireStateByDeviceRule(_ context.Context, de }  func newRulesTestHandler(rules *fakeSocRuleStore, cleaner *fakeFireStateCleaner) *Handler {-	h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(&mockReader{}, nil, testSerial, testToken) 	h.rules = rules 	h.fireState = cleaner 	// Fix the clock and the UUID generator so tests are deterministic.@@ -181,7 +181,7 @@ func TestHandleCreateRule_ValidationParityWithAC1_3(t *testing.T) {  func TestHandleCreateRule_Returns409OnEleventhRule(t *testing.T) { 	rules := newFakeSocRuleStore()-	for i := 0; i < 10; i++ {+	for i := range 10 { 		rules.rules["dev-1"] = append(rules.rules["dev-1"], dynamo.SoCRuleItem{ 			DeviceID:  "dev-1", 			RuleID:    fmt.Sprintf("rule-%d", i),
internal/api/status_simulate_test.go Modified +9 / -9
diff --git a/internal/api/status_simulate_test.go b/internal/api/status_simulate_test.goindex 1d75b89..229e418 100644--- a/internal/api/status_simulate_test.go+++ b/internal/api/status_simulate_test.go@@ -102,7 +102,7 @@ func TestHandleStatusSimulateWaterfall(t *testing.T) { 					return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 				}, 			}-			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(mr, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			resp, err := h.Handle(context.Background(), simulateStatusRequest(strconv.Itoa(tc.watts)))@@ -157,7 +157,7 @@ func TestHandleStatusSimulateEmptyByEarlier(t *testing.T) { 	}  	// Real (no simulation) cutoff.-	hReal := NewHandler(mkReader(), nil, testSerial, testToken, "11:00", "14:00")+	hReal := newTestHandlerFor(mkReader(), nil, testSerial, testToken) 	hReal.nowFunc = func() time.Time { return now } 	realResp, err := hReal.Handle(context.Background(), simulateStatusRequest("")) 	require.NoError(t, err)@@ -166,7 +166,7 @@ func TestHandleStatusSimulateEmptyByEarlier(t *testing.T) { 	require.NotNil(t, realSR.Rolling15m.EstimatedCutoff, "precondition: real rolling cutoff present")  	// Simulated cutoff with +2000 W.-	hSim := NewHandler(mkReader(), nil, testSerial, testToken, "11:00", "14:00")+	hSim := newTestHandlerFor(mkReader(), nil, testSerial, testToken) 	hSim.nowFunc = func() time.Time { return now } 	simResp, err := hSim.Handle(context.Background(), simulateStatusRequest("2000")) 	require.NoError(t, err)@@ -216,7 +216,7 @@ func TestHandleStatusSimulateNoEmptyByWhenCharging(t *testing.T) { 			return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), simulateStatusRequest("1700"))@@ -252,7 +252,7 @@ func TestHandleStatusSimulateOffpeakBoundaryGate(t *testing.T) { 			return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), simulateStatusRequest("200"))@@ -281,7 +281,7 @@ func TestHandleStatusSimulateStaleGate(t *testing.T) { 			}, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), simulateStatusRequest("1700"))@@ -318,7 +318,7 @@ func TestHandleStatusSimulateInvalidParam(t *testing.T) { 					}, nil 				}, 			}-			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(mr, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			resp, err := h.Handle(context.Background(), simulateStatusRequest(watts))@@ -350,7 +350,7 @@ func TestHandleStatusSimulateBoundaryAccepted(t *testing.T) { 					return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 				}, 			}-			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(mr, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			resp, err := h.Handle(context.Background(), simulateStatusRequest(watts))@@ -376,7 +376,7 @@ func TestHandleStatusNoParamUnchanged(t *testing.T) { 			return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 		}, 	}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), simulateStatusRequest(""))
internal/api/status_test.go Modified +48 / -47
diff --git a/internal/api/status_test.go b/internal/api/status_test.goindex b42a22d..be0d905 100644--- a/internal/api/status_test.go+++ b/internal/api/status_test.go@@ -9,6 +9,7 @@ import (  	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" 	"github.com/aws/aws-lambda-go/events" 	"github.com/stretchr/testify/assert" 	"github.com/stretchr/testify/require"@@ -79,7 +80,7 @@ func TestHandleStatusAllDataPresent(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -139,7 +140,7 @@ func TestHandleStatusStaleLatestReading_OmitsLive(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -177,7 +178,7 @@ func TestHandleStatusStalenessBoundary(t *testing.T) { 				}, 			} -			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(mr, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			resp, err := h.Handle(context.Background(), statusRequest())@@ -200,7 +201,7 @@ func TestHandleStatusNoReadings(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return fixedNow() }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -235,7 +236,7 @@ func TestHandleStatusLow24hNoReadingsToday(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -255,7 +256,7 @@ func TestHandleStatusOffpeakPendingBeforeWindowNoSplit(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -307,7 +308,7 @@ func TestHandleStatusOffpeakInProgress(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -341,7 +342,7 @@ func TestHandleStatusOffpeakComplete(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -372,7 +373,7 @@ func TestHandleStatusNoTodayEnergy(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -402,7 +403,7 @@ func TestHandleStatusComputedEnergyNoDaily(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -442,7 +443,7 @@ func TestHandleStatusReconciledEnergy(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -476,7 +477,7 @@ func TestHandleStatusSingleReadingWithDaily(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -506,7 +507,7 @@ func TestHandleStatusSystemMissingFallbackCapacity(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -525,7 +526,7 @@ func TestHandleStatusSystemZeroCobatFallback(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -575,7 +576,7 @@ func TestHandleStatusDynamoDBError(t *testing.T) {  	for name, tc := range tests { 		t.Run(name, func(t *testing.T) {-			h := NewHandler(tc.mock, nil, testSerial, testToken, "11:00", "14:00")+			h := newTestHandlerFor(tc.mock, nil, testSerial, testToken) 			h.nowFunc = func() time.Time { return now }  			resp, err := h.Handle(context.Background(), statusRequest())@@ -597,7 +598,7 @@ func TestHandleStatusOffpeakNotFound(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -631,7 +632,7 @@ func TestHandleStatusRollingAvgFewerThan2Readings(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -674,7 +675,7 @@ func TestHandleStatusCutoffSuppressedWhenAfterOffpeak(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -712,7 +713,7 @@ func TestHandleStatusCutoffShownWhenBeforeOffpeak(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -728,11 +729,11 @@ func TestHandleStatusCutoffShownWhenBeforeOffpeak(t *testing.T) { 		"rolling15min.estimatedCutoffTime should be present when cutoff is before next off-peak") } -// TestHandleStatusCutoffShownWithInvalidOffpeakConfig verifies that when the-// off-peak window is misconfigured (unparseable), the cutoff filter falls-// through as a no-op — a computed cutoff is still returned as-is rather than-// silently suppressed.-func TestHandleStatusCutoffShownWithInvalidOffpeakConfig(t *testing.T) {+// TestHandleStatusCutoffShownWhenNoPlanPricesTheDay verifies that when no+// plan prices today (and so there is no free window to charge in), the cutoff+// filter falls through as a no-op — a computed cutoff is still returned as-is+// rather than silently suppressed.+func TestHandleStatusCutoffShownWhenNoPlanPricesTheDay(t *testing.T) { 	now := time.Date(2026, 4, 15, 7, 0, 0, 0, sydneyTZ) 	nowUnix := now.Unix() @@ -750,7 +751,7 @@ func TestHandleStatusCutoffShownWithInvalidOffpeakConfig(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "bad", "also-bad")+	h := handlerWithPlans(mr) // no plans at all 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -760,10 +761,10 @@ func TestHandleStatusCutoffShownWithInvalidOffpeakConfig(t *testing.T) { 	sr := parseStatusResponse(t, resp) 	require.NotNil(t, sr.Battery) 	require.NotNil(t, sr.Battery.EstimatedCutoff,-		"cutoff should be returned when off-peak config is invalid (no suppression)")+		"cutoff should be returned when the day is unpriced (no suppression)") 	require.NotNil(t, sr.Rolling15m) 	require.NotNil(t, sr.Rolling15m.EstimatedCutoff,-		"rolling cutoff should be returned when off-peak config is invalid")+		"rolling cutoff should be returned when the day is unpriced") }  // TestHandleStatusCutoffSuppressedDuringOffpeak verifies that when "now" is@@ -794,7 +795,7 @@ func TestHandleStatusCutoffSuppressedDuringOffpeak(t *testing.T) { 		}, 	} -	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { return now }  	resp, err := h.Handle(context.Background(), statusRequest())@@ -821,7 +822,7 @@ func TestHandleStatusBundlesNote(t *testing.T) { 				return &dynamo.NoteItem{Date: date, Text: "Away in Bali", UpdatedAt: "2026-04-15T01:00:00Z"}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -837,7 +838,7 @@ func TestHandleStatusBundlesNote(t *testing.T) { 		mr := &mockReader{ 			getNoteFn: func(_ context.Context, _, _ string) (*dynamo.NoteItem, error) { return nil, nil }, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -855,7 +856,7 @@ func TestHandleStatusBundlesNote(t *testing.T) { 				return nil, errors.New("throttled") 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -889,7 +890,7 @@ func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -925,7 +926,7 @@ func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -956,7 +957,7 @@ func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -980,15 +981,15 @@ func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		// Empty off-peak config → ParseOffpeakWindow returns ok=false → no boundary.-		h := NewHandler(mr, nil, testSerial, testToken, "", "")+		// No plan prices today or tomorrow → no window boundary at all.+		h := handlerWithPlans(mr) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest()) 		require.NoError(t, err) 		sr := parseStatusResponse(t, resp) 		require.NotNil(t, sr.Battery)-		assert.Nil(t, sr.Battery.CantEmptyBeforeOffpeak, "flag must be nil when off-peak config is missing")+		assert.Nil(t, sr.Battery.CantEmptyBeforeOffpeak, "flag must be nil when no plan supplies a window") 	})  	t.Run("e) Sydney DST transition day", func(t *testing.T) {@@ -1012,7 +1013,7 @@ func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -1025,7 +1026,7 @@ func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) {  		// Cross-check the boundary the integration uses: nextOpStart on 		// the DST day must be 11:00 AEDT (UTC+11), not 11:00 AEST.-		nextOp, ok := nextOffpeakStart(now.In(sydneyTZ), "11:00", "14:00")+		nextOp, ok := nextOffpeakStart(now.In(sydneyTZ), []plan.Plan{planRow("p", "2000-01-01", nil, freeBand("11:00", "14:00")).Plan()}) 		require.True(t, ok) 		_, offsetSec := nextOp.Zone() 		assert.Equal(t, 11*3600, offsetSec, "off-peak start sits in AEDT after the DST gap")@@ -1046,7 +1047,7 @@ func TestHandleStatusCantEmptyBeforeOffpeak(t *testing.T) { 				return nil, nil // not found → fallback capacity used 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return now }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -1085,7 +1086,7 @@ func TestHandleStatusProjectedEndSoc(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return insideWindow }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -1107,7 +1108,7 @@ func TestHandleStatusProjectedEndSoc(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return outside }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -1129,7 +1130,7 @@ func TestHandleStatusProjectedEndSoc(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return insideWindow }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -1151,7 +1152,7 @@ func TestHandleStatusProjectedEndSoc(t *testing.T) { 		}  		// Unsimulated call.-		hPlain := NewHandler(newReader(), nil, testSerial, testToken, "11:00", "14:00")+		hPlain := newTestHandlerFor(newReader(), nil, testSerial, testToken) 		hPlain.nowFunc = func() time.Time { return insideWindow } 		respPlain, err := hPlain.Handle(context.Background(), simulateStatusRequest("")) 		require.NoError(t, err)@@ -1160,7 +1161,7 @@ func TestHandleStatusProjectedEndSoc(t *testing.T) { 		require.NotNil(t, srPlain.Offpeak.ProjectedEndSoc)  		// Simulated call with added load.-		hSim := NewHandler(newReader(), nil, testSerial, testToken, "11:00", "14:00")+		hSim := newTestHandlerFor(newReader(), nil, testSerial, testToken) 		hSim.nowFunc = func() time.Time { return insideWindow } 		respSim, err := hSim.Handle(context.Background(), simulateStatusRequest("3000")) 		require.NoError(t, err)@@ -1182,7 +1183,7 @@ func TestHandleStatusProjectedEndSoc(t *testing.T) { 				return &dynamo.SystemItem{SysSn: serial, Cobat: 13.34}, nil 			}, 		}-		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h := newTestHandlerFor(mr, nil, testSerial, testToken) 		h.nowFunc = func() time.Time { return outside }  		resp, err := h.Handle(context.Background(), statusRequest())@@ -1204,7 +1205,7 @@ func TestHandleStatusSingleNowCapture(t *testing.T) { 	now := fixedNow()  	mr := &mockReader{}-	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h := newTestHandlerFor(mr, nil, testSerial, testToken) 	h.nowFunc = func() time.Time { 		callCount++ 		return now
internal/api/status.go Modified +45 / -18
diff --git a/internal/api/status.go b/internal/api/status.goindex 9fd3f99..d38788f 100644--- a/internal/api/status.go+++ b/internal/api/status.go@@ -7,6 +7,7 @@ import (  	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" 	"github.com/aws/aws-lambda-go/events" 	"golang.org/x/sync/errgroup" )@@ -52,6 +53,7 @@ func (h *Handler) handleStatus(ctx context.Context, req events.LambdaFunctionURL 		sysItem     *dynamo.SystemItem 		opItem      *dynamo.OffpeakItem 		deItem      *dynamo.DailyEnergyItem+		plans       []plan.Plan 	)  	g, gctx := errgroup.WithContext(ctx)@@ -61,6 +63,14 @@ func (h *Handler) handleStatus(ctx context.Context, req events.LambdaFunctionURL 		allReadings = items 		return err 	})+	// Plans join the gated queries (Q25): the table holds a handful of rows,+	// and a read failure must fail the request rather than resolve as "no+	// plan" (Q14).+	g.Go(func() error {+		rows, err := h.listPlans(gctx)+		plans = rows+		return err+	}) 	g.Go(func() error { 		item, err := h.reader.GetSystem(gctx, h.serial) 		sysItem = item@@ -132,11 +142,15 @@ func (h *Handler) handleStatus(ctx context.Context, req events.LambdaFunctionURL 		CutoffPercent: cutoffPercent, 	} +	// todayWindow is the free window of the plan pricing today (AC 4.1); nil+	// when that plan has no free band or no plan prices today.+	todayWindow := resolveOffpeakWindow(plans, today)+ 	// nextOpWindowStart is the absolute Sydney-local time of the next off-peak 	// window start. Cutoff predictions at or after this boundary are 	// suppressed — the battery will be charged during that window, so any 	// projected cutoff past the boundary never actually occurs.-	nextOpWindowStart, hasOffpeakBoundary := nextOffpeakStart(now, h.offpeakStart, h.offpeakEnd)+	nextOpWindowStart, hasOffpeakBoundary := nextOffpeakStart(now, plans)  	if liveFresh { 		latest := allReadings[len(allReadings)-1]@@ -169,7 +183,7 @@ func (h *Handler) handleStatus(ctx context.Context, req events.LambdaFunctionURL 				Now:                 now, 				NextOpStart:         nextOpWindowStart, 				HasBoundary:         hasOffpeakBoundary,-				WithinOffpeakWindow: withinOffpeakWindow(now, h.offpeakStart, h.offpeakEnd),+				WithinOffpeakWindow: withinOffpeakWindow(now, todayWindow), 			}) 		} 	}@@ -239,22 +253,25 @@ func (h *Handler) handleStatus(ctx context.Context, req events.LambdaFunctionURL 	} 	resp.TodayEnergy = reconcileEnergy(computedEnergy, storedEnergy) -	// Off-peak data — always includes window times, plus deltas when-	// complete (from the finalised row) or pending today (live-integrated-	// from the readings already in memory for live compute).-	resp.Offpeak = buildOffpeak(opItem, allReadings, now, h.offpeakStart, h.offpeakEnd)+	// Off-peak data — window times plus deltas when complete (from the+	// finalised row) or pending today (live-integrated from the readings+	// already in memory for live compute). Null on a day with no free window.+	resp.Offpeak = buildOffpeak(opItem, allReadings, now, todayWindow)  	// Projected SoC at the off-peak window end (T-1533). Computed only on the 	// fresh-live branch — same gate as EstimatedCutoff (AC 2.2) — and reusing 	// the `capacity` variable already resolved above so the two figures never 	// disagree about capacity (AC 1.4). projectOffpeakEndSoc returns nil-	// outside the window, on an unparseable window, or for non-positive+	// outside the window, on a day with no free window, or for non-positive 	// capacity; it never reads Pbat or the simulated load, so an active-	// simulation leaves the projection unchanged (AC 1.9, AC 2.4). resp.Offpeak-	// is always non-nil here (buildOffpeak always returns window times).-	if liveFresh {+	// simulation leaves the projection unchanged (AC 1.9, AC 2.4). A non-nil+	// projection implies a resolved window, which implies resp.Offpeak is+	// non-nil — both derive from the same todayWindow. The nil check keeps that+	// an invariant rather than a nil dereference in the 10s-polled hot path if+	// either function's window handling later diverges.+	if liveFresh && resp.Offpeak != nil { 		latest := allReadings[len(allReadings)-1]-		if p := projectOffpeakEndSoc(latest.Soc, capacity, now, h.offpeakStart, h.offpeakEnd); p != nil {+		if p := projectOffpeakEndSoc(latest.Soc, capacity, now, todayWindow); p != nil { 			resp.Offpeak.ProjectedEndSoc = p 		} 	}@@ -263,7 +280,7 @@ func (h *Handler) handleStatus(ctx context.Context, req events.LambdaFunctionURL 	// two windows bracketing off-peak, independent of reconcileEnergy so the 	// off-peak sampling artifact never lands on peak (T-1421). Absent until the 	// morning window has enough samples; iOS then uses its residual fallback.-	if peak, ok := livePeakGridImport(allReadings, now, h.offpeakStart, h.offpeakEnd); ok {+	if peak, ok := livePeakGridImport(allReadings, now, todayWindow); ok { 		resp.PeakGridImportKwh = floatPtr(derivedstats.RoundEnergy(peak)) 	} @@ -285,21 +302,31 @@ func filterReadings(readings []dynamo.ReadingItem, from, to int64) []dynamo.Read  // buildOffpeak constructs the OffpeakData response. //-// Window times are always included. Deltas come from one of two sources:+// Returns nil when the day has no free window — either its plan has no free+// band or no plan prices it (Q35/AC 4.4). The whole object is absent in that+// case rather than carrying empty window strings, because a client that+// received a window-less object would have nothing to render and might+// substitute its own default window constants.+//+// When a window exists its times are always included. Deltas come from one of+// two sources: //   - Complete record: the poller has finalised the five integration-sourced //     deltas, served directly from the row. //   - Pending record on today, with now inside the window: live-integrate-//     readings over [offpeak-start, min(now, offpeak-end)). Battery delta+//     readings over [window start, min(now, window end)). Battery delta //     percent is unknown mid-window because we lack a fixed end SOC. // // Returns deltas as nil when neither source is usable (no row, pending row // before the window opens, or sparse readings). func buildOffpeak(item *dynamo.OffpeakItem, readings []dynamo.ReadingItem, now time.Time,-	offpeakStart, offpeakEnd string,+	window *offpeakWindow, ) *OffpeakData {+	if window == nil {+		return nil+	} 	od := &OffpeakData{-		WindowStart: offpeakStart,-		WindowEnd:   offpeakEnd,+		WindowStart: window.startHHMM(),+		WindowEnd:   window.endHHMM(), 	} 	if item == nil { 		return od@@ -313,7 +340,7 @@ func buildOffpeak(item *dynamo.OffpeakItem, readings []dynamo.ReadingItem, now t 	case dynamo.OffpeakStatusComplete: 		deltas, ok = offpeakDeltas(*item) 	case dynamo.OffpeakStatusPending:-		deltas, ok = liveOffpeakDeltas(readings, now, offpeakStart, offpeakEnd)+		deltas, ok = liveOffpeakDeltas(readings, now, window) 	} 	if !ok { 		return od
internal/api/testdata/pricing_costs.json Added +361 / -0
diff --git a/internal/api/testdata/pricing_costs.json b/internal/api/testdata/pricing_costs.jsonnew file mode 100644index 0000000..10a63f0--- /dev/null+++ b/internal/api/testdata/pricing_costs.json@@ -0,0 +1,361 @@+[+  {+    "name": "tier2-offpeak-and-server-peak",+    "description": "Legacy formula, both values present: import prices the server-computed peak kWh, savings price the measured off-peak kWh. The two deliberately do not sum to eInput.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "11:00", "end": "14:00", "free": true }],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 20.0,+      "eOutput": 15.0,+      "peakGridImportKwh": 13.8,+      "offpeak": {+        "gridImportKwh": 6.0,+        "windowStart": "11:00",+        "windowEnd": "14:00",+        "integratedAt": "2026-04-12T04:00:00Z",+        "sampleCount": 900+      },+      "bandImports": null+    },+    "expected": { "tier": 2, "importCost": 4.83, "feedInIncome": 0.75, "net": 4.08, "savings": 2.1 }+  },+  {+    "name": "tier2-offpeak-no-server-peak",+    "description": "Legacy formula, no server peak: import falls back to the eInput minus off-peak residual.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "11:00", "end": "14:00", "free": true }],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 20.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 6.0,+        "windowStart": "11:00",+        "windowEnd": "14:00",+        "integratedAt": "2026-04-12T04:00:00Z",+        "sampleCount": 900+      },+      "bandImports": null+    },+    "expected": { "tier": 2, "importCost": 4.9, "feedInIncome": 0.75, "net": 4.15, "savings": 2.1 }+  },+  {+    "name": "tier2-no-offpeak-server-peak",+    "description": "Legacy formula, no off-peak row: import prices the server peak and savings are $0.00.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "11:00", "end": "14:00", "free": true }],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 20.0,+      "eOutput": 15.0,+      "peakGridImportKwh": 13.8,+      "offpeak": null,+      "bandImports": null+    },+    "expected": { "tier": 2, "importCost": 4.83, "feedInIncome": 0.75, "net": 4.08, "savings": 0.0 }+  },+  {+    "name": "tier2-no-offpeak-no-server-peak",+    "description": "Legacy formula, neither value present: all of eInput is priced at the plan rate.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "11:00", "end": "14:00", "free": true }],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 20.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": null,+      "bandImports": null+    },+    "expected": { "tier": 2, "importCost": 7.0, "feedInIncome": 0.75, "net": 6.25, "savings": 0.0 }+  },+  {+    "name": "tier2-zero-clamp",+    "description": "Off-peak import exceeding the day's total clamps the residual to zero rather than producing a negative import cost.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "11:00", "end": "14:00", "free": true }],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 5.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 6.0,+        "windowStart": "11:00",+        "windowEnd": "14:00",+        "integratedAt": "2026-04-12T04:00:00Z",+        "sampleCount": 900+      },+      "bandImports": null+    },+    "expected": { "tier": 2, "importCost": 0.0, "feedInIncome": 0.75, "net": -0.75, "savings": 2.1 }+  },+  {+    "name": "tier2-no-energy-recorded",+    "description": "A day with no recorded energy is priced, not skipped: every line is zero.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "11:00", "end": "14:00", "free": true }],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": null,+      "eOutput": null,+      "peakGridImportKwh": null,+      "offpeak": null,+      "bandImports": null+    },+    "expected": { "tier": 2, "importCost": 0.0, "feedInIncome": 0.0, "net": 0.0, "savings": 0.0 }+  },+  {+    "name": "tier1-new-plan-full-split",+    "description": "The banded path: each rated band priced at its own rate, savings from the off-peak row whose geometry matches the plan's free window.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 23.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 3.0,+        "windowStart": "10:00",+        "windowEnd": "15:00",+        "integratedAt": "2026-08-02T05:00:00Z",+        "sampleCount": 1500+      },+      "bandImports": [+        { "start": "00:00", "end": "01:00", "kwh": 1.0 },+        { "start": "01:00", "end": "06:00", "kwh": 4.0 },+        { "start": "06:00", "end": "10:00", "kwh": 2.0 },+        { "start": "15:00", "end": "24:00", "kwh": 8.0 }+      ]+    },+    "expected": { "tier": 1, "importCost": 4.97, "feedInIncome": 0.75, "net": 4.22, "savings": 1.05 }+  },+  {+    "name": "tier1-no-free-band",+    "description": "A plan without a free band needs no off-peak row: the rated bands cover the whole day and savings are $0.00.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }],+      "feedInRate": 0.05+    },+    "day": {+      "eInput": 15.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": null,+      "bandImports": [+        { "start": "00:00", "end": "01:00", "kwh": 1.0 },+        { "start": "01:00", "end": "06:00", "kwh": 4.0 },+        { "start": "06:00", "end": "24:00", "kwh": 10.0 }+      ]+    },+    "expected": { "tier": 1, "importCost": 4.97, "feedInIncome": 0.75, "net": 4.22, "savings": 0.0 }+  },+  {+    "name": "tier1-pre-feature-offpeak-row-geometry",+    "description": "An off-peak row without a geometry snapshot is treated as 11:00-14:00, the only window it can have been computed under, so it matches a migrated plan's free window. Same figures as tier2-offpeak-no-server-peak — the AC 5.2 property.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "11:00", "end": "14:00", "free": true }],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 20.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 6.0,+        "windowStart": null,+        "windowEnd": null,+        "integratedAt": null,+        "sampleCount": 0+      },+      "bandImports": [+        { "start": "00:00", "end": "11:00", "kwh": 5.0 },+        { "start": "14:00", "end": "24:00", "kwh": 9.0 }+      ]+    },+    "expected": { "tier": 1, "importCost": 4.9, "feedInIncome": 0.75, "net": 4.15, "savings": 2.1 }+  },+  {+    "name": "tier3-sparse-complete-offpeak-row",+    "description": "An off-peak row with integratedAt set but no samples is a zero-delta artifact, not a measured zero, so the free import is unresolvable and a multi-rate plan falls to the fallback.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 23.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 0.0,+        "windowStart": "10:00",+        "windowEnd": "15:00",+        "integratedAt": "2026-08-02T05:00:00Z",+        "sampleCount": 0+      },+      "bandImports": [+        { "start": "00:00", "end": "01:00", "kwh": 1.0 },+        { "start": "01:00", "end": "06:00", "kwh": 4.0 },+        { "start": "06:00", "end": "10:00", "kwh": 2.0 },+        { "start": "15:00", "end": "24:00", "kwh": 8.0 }+      ]+    },+    "expected": { "tier": 3, "importCost": 8.05, "feedInIncome": 0.75, "net": 7.3, "savings": 0.0 }+  },+  {+    "name": "tier3-band-geometry-mismatch",+    "description": "A stored split captured under the previous free window no longer matches the plan's rated segments, so the day degrades to the fallback (Q16).",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 23.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 3.0,+        "windowStart": "10:00",+        "windowEnd": "15:00",+        "integratedAt": "2026-08-02T05:00:00Z",+        "sampleCount": 1500+      },+      "bandImports": [+        { "start": "00:00", "end": "01:00", "kwh": 1.0 },+        { "start": "01:00", "end": "06:00", "kwh": 4.0 },+        { "start": "06:00", "end": "11:00", "kwh": 2.5 },+        { "start": "14:00", "end": "24:00", "kwh": 9.0 }+      ]+    },+    "expected": { "tier": 3, "importCost": 8.05, "feedInIncome": 0.75, "net": 7.3, "savings": 0.0 }+  },+  {+    "name": "tier3-offpeak-geometry-mismatch",+    "description": "The rated split matches but the off-peak row was integrated under the old window, so the free import is not resolvable for this plan.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 23.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 3.0,+        "windowStart": "11:00",+        "windowEnd": "14:00",+        "integratedAt": "2026-08-02T05:00:00Z",+        "sampleCount": 1500+      },+      "bandImports": [+        { "start": "00:00", "end": "01:00", "kwh": 1.0 },+        { "start": "01:00", "end": "06:00", "kwh": 4.0 },+        { "start": "06:00", "end": "10:00", "kwh": 2.0 },+        { "start": "15:00", "end": "24:00", "kwh": 8.0 }+      ]+    },+    "expected": { "tier": 3, "importCost": 8.05, "feedInIncome": 0.75, "net": 7.3, "savings": 0.0 }+  },+  {+    "name": "tier3-multi-rate-no-split",+    "description": "A multi-rate plan with no stored split cannot be priced per band, so all import is billed at the highest rate with no savings (AC 3.6).",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 23.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 3.0,+        "windowStart": "10:00",+        "windowEnd": "15:00",+        "integratedAt": "2026-08-02T05:00:00Z",+        "sampleCount": 1500+      },+      "bandImports": null+    },+    "expected": { "tier": 3, "importCost": 8.05, "feedInIncome": 0.75, "net": 7.3, "savings": 0.0 }+  },+  {+    "name": "tier3-partially-known-split",+    "description": "A split counts as available only when every rated band's kWh is known; a partial split is unavailable, not partially used (AC 3.6).",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "day": {+      "eInput": 23.0,+      "eOutput": 15.0,+      "peakGridImportKwh": null,+      "offpeak": {+        "gridImportKwh": 3.0,+        "windowStart": "10:00",+        "windowEnd": "15:00",+        "integratedAt": "2026-08-02T05:00:00Z",+        "sampleCount": 1500+      },+      "bandImports": [+        { "start": "00:00", "end": "01:00", "kwh": 1.0 },+        { "start": "01:00", "end": "06:00", "kwh": 4.0 }+      ]+    },+    "expected": { "tier": 3, "importCost": 8.05, "feedInIncome": 0.75, "net": 7.3, "savings": 0.0 }+  }+]
internal/api/testdata/pricing_segments.json Added +130 / -0
diff --git a/internal/api/testdata/pricing_segments.json b/internal/api/testdata/pricing_segments.jsonnew file mode 100644index 0000000..fe81232--- /dev/null+++ b/internal/api/testdata/pricing_segments.json@@ -0,0 +1,130 @@+[+  {+    "name": "flat-plan-no-windows",+    "description": "A plan with no exception windows is one default-rate segment covering the whole day.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [],+      "feedInRate": 0.05+    },+    "segments": [+      { "start": "00:00", "end": "24:00", "free": false, "rate": 0.35 }+    ]+  },+  {+    "name": "current-plan",+    "description": "The plan in production before the switch: free 11:00-14:00, one flat rate otherwise.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "11:00", "end": "14:00", "free": true }],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "segments": [+      { "start": "00:00", "end": "11:00", "free": false, "rate": 0.35 },+      { "start": "11:00", "end": "14:00", "free": true, "rate": 0 },+      { "start": "14:00", "end": "24:00", "free": false, "rate": 0.35 }+    ]+  },+  {+    "name": "new-plan",+    "description": "The incoming time-of-use plan: free 10:00-15:00, cheaper 01:00-06:00, standard otherwise.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "segments": [+      { "start": "00:00", "end": "01:00", "free": false, "rate": 0.35 },+      { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 },+      { "start": "06:00", "end": "10:00", "free": false, "rate": 0.35 },+      { "start": "10:00", "end": "15:00", "free": true, "rate": 0 },+      { "start": "15:00", "end": "24:00", "free": false, "rate": 0.35 }+    ]+  },+  {+    "name": "window-at-day-start",+    "description": "A window starting at 00:00 leaves no leading default segment.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "00:00", "end": "06:00", "free": false, "rate": 0.28 }],+      "feedInRate": 0.05+    },+    "segments": [+      { "start": "00:00", "end": "06:00", "free": false, "rate": 0.28 },+      { "start": "06:00", "end": "24:00", "free": false, "rate": 0.35 }+    ]+  },+  {+    "name": "window-at-day-end",+    "description": "A window ending at 24:00 leaves no trailing default segment.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [{ "start": "18:00", "end": "24:00", "free": false, "rate": 0.28 }],+      "feedInRate": 0.05+    },+    "segments": [+      { "start": "00:00", "end": "18:00", "free": false, "rate": 0.35 },+      { "start": "18:00", "end": "24:00", "free": false, "rate": 0.28 }+    ]+  },+  {+    "name": "windows-tile-the-day",+    "description": "Windows covering the whole day leave zero-width default remainders, which are not emitted.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "00:00", "end": "10:00", "free": false, "rate": 0.28 },+        { "start": "10:00", "end": "15:00", "free": true },+        { "start": "15:00", "end": "24:00", "free": false, "rate": 0.3 }+      ],+      "feedInRate": 0.05,+      "savingsReferenceRate": 0.35+    },+    "segments": [+      { "start": "00:00", "end": "10:00", "free": false, "rate": 0.28 },+      { "start": "10:00", "end": "15:00", "free": true, "rate": 0 },+      { "start": "15:00", "end": "24:00", "free": false, "rate": 0.3 }+    ]+  },+  {+    "name": "abutting-same-rate-windows-not-merged",+    "description": "Q26: abutting segments carrying the same rate stay separate so the stored split's geometry is stable.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "00:00", "end": "06:00", "free": false, "rate": 0.35 },+        { "start": "06:00", "end": "12:00", "free": false, "rate": 0.35 }+      ],+      "feedInRate": 0.05+    },+    "segments": [+      { "start": "00:00", "end": "06:00", "free": false, "rate": 0.35 },+      { "start": "06:00", "end": "12:00", "free": false, "rate": 0.35 },+      { "start": "12:00", "end": "24:00", "free": false, "rate": 0.35 }+    ]+  },+  {+    "name": "unsorted-windows",+    "description": "Windows are segmented in chronological order regardless of the order they were entered in.",+    "plan": {+      "defaultRate": 0.35,+      "windows": [+        { "start": "18:00", "end": "20:00", "free": false, "rate": 0.4 },+        { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 }+      ],+      "feedInRate": 0.05+    },+    "segments": [+      { "start": "00:00", "end": "01:00", "free": false, "rate": 0.35 },+      { "start": "01:00", "end": "06:00", "free": false, "rate": 0.28 },+      { "start": "06:00", "end": "18:00", "free": false, "rate": 0.35 },+      { "start": "18:00", "end": "20:00", "free": false, "rate": 0.4 },+      { "start": "20:00", "end": "24:00", "free": false, "rate": 0.35 }+    ]+  }+]
internal/config/config_test.go Modified +6 / -94
diff --git a/internal/config/config_test.go b/internal/config/config_test.goindex 33c04bf..0aecbd5 100644--- a/internal/config/config_test.go+++ b/internal/config/config_test.go@@ -14,14 +14,13 @@ func fullEnv() map[string]string { 		"ALPHA_APP_ID":       "test-app-id", 		"ALPHA_APP_SECRET":   "test-app-secret", 		"SYSTEM_SERIAL":      "AB1234",-		"OFFPEAK_START":      "11:00",-		"OFFPEAK_END":        "14:00", 		"AWS_REGION":         "ap-southeast-2", 		"TABLE_READINGS":     "flux-readings", 		"TABLE_DAILY_ENERGY": "flux-daily-energy", 		"TABLE_DAILY_POWER":  "flux-daily-power", 		"TABLE_SYSTEM":       "flux-system", 		"TABLE_OFFPEAK":      "flux-offpeak",+		"TABLE_PRICING":      "flux-pricing", 	} } @@ -43,8 +42,7 @@ func TestLoad_ValidConfig(t *testing.T) { 	assert.Equal(t, "test-app-id", cfg.AppID) 	assert.Equal(t, "test-app-secret", cfg.AppSecret) 	assert.Equal(t, "AB1234", cfg.Serial)-	assert.Equal(t, 11*time.Hour, cfg.OffpeakStart)-	assert.Equal(t, 14*time.Hour, cfg.OffpeakEnd)+	assert.Equal(t, "flux-pricing", cfg.TablePricing) 	assert.Equal(t, "ap-southeast-2", cfg.AWSRegion) 	assert.Equal(t, "flux-readings", cfg.TableReadings) 	assert.Equal(t, "flux-daily-energy", cfg.TableDailyEnergy)@@ -84,14 +82,13 @@ func TestLoad_MissingRequiredVars(t *testing.T) { 		"ALPHA_APP_ID", 		"ALPHA_APP_SECRET", 		"SYSTEM_SERIAL",-		"OFFPEAK_START",-		"OFFPEAK_END", 		"AWS_REGION", 		"TABLE_READINGS", 		"TABLE_DAILY_ENERGY", 		"TABLE_DAILY_POWER", 		"TABLE_SYSTEM", 		"TABLE_OFFPEAK",+		"TABLE_PRICING", 	}  	for _, varName := range requiredVars {@@ -107,57 +104,8 @@ func TestLoad_MissingRequiredVars(t *testing.T) { 	} } -func TestLoad_InvalidOffpeakTimes(t *testing.T) {-	tests := map[string]struct {-		start string-		end   string-		errOn string // substring expected in error-	}{-		"invalid start format": {-			start: "not-a-time",-			end:   "14:00",-			errOn: "OFFPEAK_START",-		},-		"invalid end format": {-			start: "11:00",-			end:   "bad",-			errOn: "OFFPEAK_END",-		},-		"start equals end": {-			start: "11:00",-			end:   "11:00",-			errOn: "OFFPEAK_START",-		},-		"start after end": {-			start: "15:00",-			end:   "14:00",-			errOn: "OFFPEAK_START",-		},-		"invalid hour in start": {-			start: "25:00",-			end:   "14:00",-			errOn: "OFFPEAK_START",-		},-		"invalid minute in end": {-			start: "11:00",-			end:   "14:61",-			errOn: "OFFPEAK_END",-		},-	}--	for name, tc := range tests {-		t.Run(name, func(t *testing.T) {-			env := fullEnv()-			env["OFFPEAK_START"] = tc.start-			env["OFFPEAK_END"] = tc.end-			setEnv(t, env)--			_, err := Load()-			require.Error(t, err)-			assert.Contains(t, err.Error(), tc.errOn)-		})-	}-}+// The off-peak window is no longer configuration: it comes from the plan+// pricing each day (Decision 2), so there is nothing here to validate.  func TestLoad_InvalidTimezone(t *testing.T) { 	env := fullEnv()@@ -176,8 +124,6 @@ func TestLoad_DryRunRelaxesAWSVars(t *testing.T) { 		"ALPHA_APP_ID":     "test-app-id", 		"ALPHA_APP_SECRET": "test-app-secret", 		"SYSTEM_SERIAL":    "AB1234",-		"OFFPEAK_START":    "11:00",-		"OFFPEAK_END":      "14:00", 	} 	setEnv(t, env) @@ -204,8 +150,6 @@ func TestLoad_DryRunStillRequiresAlphaVars(t *testing.T) { 				"ALPHA_APP_ID":     "test-app-id", 				"ALPHA_APP_SECRET": "test-app-secret", 				"SYSTEM_SERIAL":    "AB1234",-				"OFFPEAK_START":    "11:00",-				"OFFPEAK_END":      "14:00", 			} 			delete(env, missing) 			setEnv(t, env)@@ -219,7 +163,7 @@ func TestLoad_DryRunStillRequiresAlphaVars(t *testing.T) {  func TestLoad_CollectsMultipleErrors(t *testing.T) { 	// When multiple vars are missing, all should be reported.-	// Set only DRY_RUN — missing all AlphaESS vars and offpeak times.+	// Set only DRY_RUN — missing every AlphaESS var. 	t.Setenv("DRY_RUN", "false") 	// Don't set any other vars. @@ -231,35 +175,3 @@ func TestLoad_CollectsMultipleErrors(t *testing.T) { 	assert.Contains(t, err.Error(), "ALPHA_APP_SECRET") 	assert.Contains(t, err.Error(), "SYSTEM_SERIAL") }--func TestParseHHMM(t *testing.T) {-	tests := map[string]struct {-		input   string-		want    time.Duration-		wantErr bool-	}{-		"valid morning":     {input: "06:30", want: 6*time.Hour + 30*time.Minute},-		"midnight":          {input: "00:00", want: 0},-		"end of day":        {input: "23:59", want: 23*time.Hour + 59*time.Minute},-		"empty string":      {input: "", wantErr: true},-		"missing colon":     {input: "1100", wantErr: true},-		"extra parts":       {input: "11:00:00", wantErr: true},-		"non-numeric hour":  {input: "ab:00", wantErr: true},-		"non-numeric min":   {input: "11:cd", wantErr: true},-		"hour out of range": {input: "24:00", wantErr: true},-		"min out of range":  {input: "11:60", wantErr: true},-		"negative hour":     {input: "-1:00", wantErr: true},-	}--	for name, tc := range tests {-		t.Run(name, func(t *testing.T) {-			got, err := parseHHMM(tc.input)-			if tc.wantErr {-				assert.Error(t, err)-				return-			}-			require.NoError(t, err)-			assert.Equal(t, tc.want, got)-		})-	}-}
internal/config/config.go Modified +10 / -61
diff --git a/internal/config/config.go b/internal/config/config.goindex 75d5165..becdb82 100644--- a/internal/config/config.go+++ b/internal/config/config.go@@ -5,8 +5,6 @@ import ( 	"fmt" 	"log/slog" 	"os"-	"strconv"-	"strings" 	"time" ) @@ -17,10 +15,11 @@ type Config struct { 	AppSecret string 	Serial    string -	// Off-peak window (duration from midnight)-	OffpeakStart time.Duration-	OffpeakEnd   time.Duration-	Location     *time.Location+	// Location is the timezone every calendar date and wall-clock boundary is+	// interpreted in. The off-peak window used to live here too; it is now a+	// property of the pricing plan that prices each day (Decision 2), so it+	// switches with the plan instead of needing a redeploy.+	Location *time.Location  	// DynamoDB table names (empty in dry-run mode) 	TableReadings     string@@ -31,6 +30,10 @@ type Config struct { 	TableDevices      string 	TableSocRules     string 	TableSocFireState string+	// TablePricing holds the band-based plans. The poller reads it (never+	// writes) to resolve each day's free window — the plan replaced the SSM+	// window parameters as the source of truth (Decision 2).+	TablePricing string  	// APNs SSM parameter paths (empty in dry-run mode and when SoC alerts 	// are not deployed). When all are set, the poller wires the alert path.@@ -65,33 +68,6 @@ func Load() (*Config, error) { 	cfg.AppSecret = requireEnv("ALPHA_APP_SECRET", &errs) 	cfg.Serial = requireEnv("SYSTEM_SERIAL", &errs) -	// Off-peak window.-	var startOK, endOK bool-	if raw := requireEnv("OFFPEAK_START", &errs); raw != "" {-		d, err := parseHHMM(raw)-		if err != nil {-			errs = append(errs, fmt.Errorf("OFFPEAK_START: %w", err))-		} else {-			cfg.OffpeakStart = d-			startOK = true-		}-	}--	if raw := requireEnv("OFFPEAK_END", &errs); raw != "" {-		d, err := parseHHMM(raw)-		if err != nil {-			errs = append(errs, fmt.Errorf("OFFPEAK_END: %w", err))-		} else {-			cfg.OffpeakEnd = d-			endOK = true-		}-	}--	if startOK && endOK && cfg.OffpeakStart >= cfg.OffpeakEnd {-		errs = append(errs, fmt.Errorf("OFFPEAK_START must be before OFFPEAK_END (%s >= %s)",-			FormatHHMM(cfg.OffpeakStart), FormatHHMM(cfg.OffpeakEnd)))-	}- 	// Timezone. 	tz := os.Getenv("TZ") 	if tz == "" {@@ -112,6 +88,7 @@ func Load() (*Config, error) { 		cfg.TableDailyPower = requireEnv("TABLE_DAILY_POWER", &errs) 		cfg.TableSystem = requireEnv("TABLE_SYSTEM", &errs) 		cfg.TableOffpeak = requireEnv("TABLE_OFFPEAK", &errs)+		cfg.TablePricing = requireEnv("TABLE_PRICING", &errs) 		// SoC alert tables and APNs SSM params are optional: when missing 		// the poller starts without the SoC alert path. Production wires 		// them via CloudFormation; integration tests leave them unset.@@ -130,7 +107,6 @@ func Load() (*Config, error) {  	slog.Debug("config loaded", 		"serial", cfg.Serial,-		"offpeak", FormatHHMM(cfg.OffpeakStart)+"-"+FormatHHMM(cfg.OffpeakEnd), 		"tz", cfg.Location.String(), 		"dry_run", cfg.DryRun, 	)@@ -147,26 +123,6 @@ func requireEnv(name string, errs *[]error) string { 	return v } -// parseHHMM parses a "HH:MM" string into a time.Duration from midnight.-func parseHHMM(s string) (time.Duration, error) {-	parts := strings.Split(s, ":")-	if len(parts) != 2 {-		return 0, fmt.Errorf("expected HH:MM format, got %q", s)-	}--	h, err := strconv.Atoi(parts[0])-	if err != nil || h < 0 || h > 23 {-		return 0, fmt.Errorf("invalid hour in %q", s)-	}--	m, err := strconv.Atoi(parts[1])-	if err != nil || m < 0 || m > 59 {-		return 0, fmt.Errorf("invalid minute in %q", s)-	}--	return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute, nil-}- // SocAlertsConfigured reports whether all SoC alert env vars are present. // When false, the poller starts without the alert pipeline — useful for // the gradual rollout described in design.md §Deploy ordering.@@ -175,10 +131,3 @@ func (c *Config) SocAlertsConfigured() bool { 		c.APNsKeyParam != "" && c.APNsKeyIDParam != "" && c.APNsTeamIDParam != "" && 		c.APNsBundleIDParam != "" }--// FormatHHMM formats a duration-from-midnight back to HH:MM for logging.-func FormatHHMM(d time.Duration) string {-	h := int(d.Hours())-	m := int(d.Minutes()) % 60-	return fmt.Sprintf("%02d:%02d", h, m)-}
internal/derivedstats/offpeak.go Modified +8 / -1
diff --git a/internal/derivedstats/offpeak.go b/internal/derivedstats/offpeak.goindex 01cb88f..72007c0 100644--- a/internal/derivedstats/offpeak.go+++ b/internal/derivedstats/offpeak.go@@ -6,6 +6,13 @@ import "time" // Returns (start, end, true) on success, or (0, 0, false) if parsing fails // or start >= end. Exported so the poller can pre-gate the summarisation // pass per requirement 1.6.+//+// "24:00" is accepted as end-of-day (1440). A plan's free band may legitimately+// run to midnight, and this window is handed straight over from+// plan.Segments — rejecting it here would silently drop Blocks and PeakPeriods+// into whole-day-rated mode on a day that does have a free band. isOffpeak+// compares a 0–1439 minute-of-day with `< end`, so 1440 needs no special case+// downstream. func ParseOffpeakWindow(startStr, endStr string) (int, int, bool) { 	parse := func(s string) (int, bool) { 		if len(s) != 5 || s[2] != ':' {@@ -13,7 +20,7 @@ func ParseOffpeakWindow(startStr, endStr string) (int, int, bool) { 		} 		h := int(s[0]-'0')*10 + int(s[1]-'0') 		m := int(s[3]-'0')*10 + int(s[4]-'0')-		if h > 23 || m > 59 {+		if h > 24 || m > 59 || (h == 24 && m != 0) { 			return 0, false 		} 		return h*60 + m, true
internal/dynamo/bandimports_test.go Added +387 / -0
diff --git a/internal/dynamo/bandimports_test.go b/internal/dynamo/bandimports_test.gonew file mode 100644index 0000000..e7b33d7--- /dev/null+++ b/internal/dynamo/bandimports_test.go@@ -0,0 +1,387 @@+package dynamo++import (+	"context"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/derivedstats"+	"github.com/ArjenSchwarz/flux/internal/plan"+	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// sampleBandImports is the rated-segment split of the incoming plan. The free+// band is deliberately absent: its import lives on the flux-offpeak row, which+// owns that quantity exclusively (Q31).+func sampleBandImports() []BandImportAttr {+	return []BandImportAttr{+		{Start: "00:00", End: "01:00", Kwh: 1.2},+		{Start: "01:00", End: "06:00", Kwh: 4.4},+		{Start: "06:00", End: "10:00", Kwh: 2.1},+		{Start: "15:00", End: "24:00", Kwh: 8.3},+	}+}++// TestDailyEnergyItem_BandImportsRoundTrip pins the storage shape: present+// when captured, omitted entirely on rows whose split was never computed.+func TestDailyEnergyItem_BandImportsRoundTrip(t *testing.T) {+	t.Parallel()+	t.Run("captured split round-trips", func(t *testing.T) {+		t.Parallel()+		in := DailyEnergyItem{+			SysSn: "AB1234", Date: "2026-08-02", EInput: 16.0,+			BandImports:     sampleBandImports(),+			BandsComputedAt: "2026-08-03T00:30:00Z",+		}+		av, err := attributevalue.MarshalMap(in)+		require.NoError(t, err)+		assert.Contains(t, av, "bandImports")+		assert.Contains(t, av, "bandsComputedAt")++		var out DailyEnergyItem+		require.NoError(t, attributevalue.UnmarshalMap(av, &out))+		assert.Equal(t, in.BandImports, out.BandImports)+		assert.Equal(t, in.BandsComputedAt, out.BandsComputedAt)+	})++	t.Run("pre-feature row carries neither attribute", func(t *testing.T) {+		t.Parallel()+		av, err := attributevalue.MarshalMap(DailyEnergyItem{SysSn: "AB1234", Date: "2026-04-12"})+		require.NoError(t, err)+		assert.NotContains(t, av, "bandImports")+		assert.NotContains(t, av, "bandsComputedAt")++		var out DailyEnergyItem+		require.NoError(t, attributevalue.UnmarshalMap(av, &out))+		assert.Nil(t, out.BandImports)+		assert.Empty(t, out.BandsComputedAt)+	})+}++// TestUpdateDailyEnergyDerived_BandGroup covers the third sentinel-gated+// group. It follows the peak group's contract (peak-from-readings Decision 3):+// each group is written only when its own sentinel is set, so a pass filling+// one never clobbers another.+func TestUpdateDailyEnergyDerived_BandGroup(t *testing.T) {+	t.Parallel()+	capture := func(t *testing.T, stats DerivedStats) *dynamodb.UpdateItemInput {+		t.Helper()+		var got *dynamodb.UpdateItemInput+		mock := &fakeDynamoAPIv2{+			updateItemFn: func(_ context.Context, params *dynamodb.UpdateItemInput) (*dynamodb.UpdateItemOutput, error) {+				got = params+				return &dynamodb.UpdateItemOutput{}, nil+			},+		}+		store := NewDynamoStore(mock, testTables())+		require.NoError(t, store.UpdateDailyEnergyDerived(context.Background(), "AB1234", "2026-08-02", stats))+		return got+	}++	t.Run("bands only — other groups absent", func(t *testing.T) {+		t.Parallel()+		got := capture(t, DerivedStats{+			BandImports:     sampleBandImports(),+			BandsComputedAt: "2026-08-03T00:30:00Z",+		})+		require.NotNil(t, got)+		expr := *got.UpdateExpression+		assert.Contains(t, expr, "bandImports")+		assert.Contains(t, expr, "bandsComputedAt")+		for _, name := range []string{"dailyUsage", "socLow", "peakPeriods", "derivedStatsComputedAt", "peakGridImportKwh", "peakComputedAt"} {+			assert.NotContains(t, expr, name, "%s must be absent when its sentinel is unset", name)+		}+		_, isList := got.ExpressionAttributeValues[":bi"].(*types.AttributeValueMemberL)+		assert.True(t, isList, "a captured split must marshal as a list")+	})++	t.Run("other groups only — band attributes absent", func(t *testing.T) {+		t.Parallel()+		got := capture(t, DerivedStats{DerivedStatsComputedAt: "2026-08-03T00:30:00Z"})+		require.NotNil(t, got)+		expr := *got.UpdateExpression+		assert.NotContains(t, expr, "bandImports")+		assert.NotContains(t, expr, "bandsComputedAt")+	})++	// Usability gate mirror of PeakGridImportKwh: when the integrator cannot+	// produce every rated segment the value stays absent, but the sentinel is+	// still set so the row is not re-attempted every hour.+	t.Run("nil split with sentinel set marshals as NULL", func(t *testing.T) {+		t.Parallel()+		got := capture(t, DerivedStats{BandsComputedAt: "2026-08-03T00:30:00Z"})+		require.NotNil(t, got)+		expr := *got.UpdateExpression+		assert.Contains(t, expr, "bandsComputedAt")+		_, isNull := got.ExpressionAttributeValues[":bi"].(*types.AttributeValueMemberNULL)+		assert.True(t, isNull, "an unavailable split must marshal as NULL")+	})++	t.Run("all three groups in one call", func(t *testing.T) {+		t.Parallel()+		peak := 12.5+		got := capture(t, DerivedStats{+			DerivedStatsComputedAt: "2026-08-03T00:30:00Z",+			PeakGridImportKwh:      &peak,+			PeakComputedAt:         "2026-08-03T00:30:00Z",+			BandImports:            sampleBandImports(),+			BandsComputedAt:        "2026-08-03T00:30:00Z",+		})+		require.NotNil(t, got)+		expr := *got.UpdateExpression+		for _, name := range []string{"dailyUsage", "socLow", "peakPeriods", "derivedStatsComputedAt", "peakGridImportKwh", "peakComputedAt", "bandImports", "bandsComputedAt"} {+			assert.Contains(t, expr, name)+		}+	})+}++// TestOffpeakItem_WindowGeometryRoundTrip pins the geometry snapshot that+// makes a later free-window edit detectable as a mismatch instead of silently+// mispricing the day.+func TestOffpeakItem_WindowGeometryRoundTrip(t *testing.T) {+	t.Parallel()+	t.Run("snapshot round-trips", func(t *testing.T) {+		t.Parallel()+		in := OffpeakItem{+			SysSn: "AB1234", Date: "2026-08-02", Status: "complete",+			GridUsageKwh: 3.2,+			WindowStart:  "10:00",+			WindowEnd:    "15:00",+		}+		av, err := attributevalue.MarshalMap(in)+		require.NoError(t, err)+		assert.Contains(t, av, "windowStart")+		assert.Contains(t, av, "windowEnd")++		var out OffpeakItem+		require.NoError(t, attributevalue.UnmarshalMap(av, &out))+		assert.Equal(t, "10:00", out.WindowStart)+		assert.Equal(t, "15:00", out.WindowEnd)+	})++	t.Run("pre-feature row carries no geometry", func(t *testing.T) {+		t.Parallel()+		av, err := attributevalue.MarshalMap(OffpeakItem{SysSn: "AB1234", Date: "2026-04-12", Status: "complete"})+		require.NoError(t, err)+		assert.NotContains(t, av, "windowStart")+		assert.NotContains(t, av, "windowEnd")+	})+}++// TestOffpeakItemGeometry covers the pre-feature default: a row with no+// snapshot can only have been computed under 11:00–14:00, the window that was+// configured for its whole lifetime.+func TestOffpeakItemGeometry(t *testing.T) {+	t.Parallel()+	tests := map[string]struct {+		item      OffpeakItem+		wantStart string+		wantEnd   string+	}{+		"snapshotted":                 {item: OffpeakItem{WindowStart: "10:00", WindowEnd: "15:00"}, wantStart: "10:00", wantEnd: "15:00"},+		"pre-feature row":             {item: OffpeakItem{}, wantStart: "11:00", wantEnd: "14:00"},+		"half-written snapshot":       {item: OffpeakItem{WindowStart: "10:00"}, wantStart: "11:00", wantEnd: "14:00"},+		"half-written snapshot (end)": {item: OffpeakItem{WindowEnd: "15:00"}, wantStart: "11:00", wantEnd: "14:00"},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			start, end := tc.item.Geometry()+			assert.Equal(t, tc.wantStart, start)+			assert.Equal(t, tc.wantEnd, end)+		})+	}+}++// TestOffpeakItemUsable pins the sparse-complete rule: a row integrated+// without any samples is a zero-delta artifact, not a measured zero, so it+// cannot be used to price a free band.+func TestOffpeakItemUsable(t *testing.T) {+	t.Parallel()+	tests := map[string]struct {+		item OffpeakItem+		want bool+	}{+		"integrated with samples":      {item: OffpeakItem{IntegratedAt: "2026-08-02T05:00:00Z", IntegrationSampleCount: 900}, want: true},+		"integrated without samples":   {item: OffpeakItem{IntegratedAt: "2026-08-02T05:00:00Z"}, want: false},+		"pre-integration snapshot row": {item: OffpeakItem{GridUsageKwh: 3.0}, want: true},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			assert.Equal(t, tc.want, tc.item.Usable())+		})+	}+}++// --- IntegrateRatedBands ---++// bandFixtureReadings synthesises a constant-power day at 60 s cadence over+// [dayStart, dayStart+24h] in loc, importing `pgrid` watts from the grid the+// whole time. The closing sample sits exactly on the next midnight so the+// integrator has a right bracket for the last band and the expected energies+// are whole numbers.+func bandFixtureReadings(dayStart time.Time, pgrid float64) []derivedstats.Reading {+	dayEnd := dayStart.AddDate(0, 0, 1)+	out := make([]derivedstats.Reading, 0, 24*60+1)+	for ts := dayStart; !ts.After(dayEnd); ts = ts.Add(time.Minute) {+		out = append(out, derivedstats.Reading{Timestamp: ts.Unix(), Pgrid: pgrid})+	}+	return out+}++// bandTestPlan is the incoming time-of-use plan: free 10:00–15:00, a cheaper+// band 01:00–06:00, the default rate otherwise.+func bandTestPlan() plan.Plan {+	savings := 0.35+	return plan.Plan{+		ID: "tou", StartDate: "2026-01-01", DefaultRate: 0.35,+		Windows: []plan.Window{+			{Start: "10:00", End: "15:00", Free: true},+			{Start: "01:00", End: "06:00", Rate: 0.28},+		},+		FeedInRate: 0.05, SavingsRefRate: &savings,+	}+}++func TestIntegrateRatedBands_GeometryMatchesRatedSegments(t *testing.T) {+	t.Parallel()+	sydney, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)+	day := time.Date(2026, 8, 12, 0, 0, 0, 0, sydney)++	// 1000 W of constant import: each band's kWh is exactly its hour count.+	bands, total, ok := IntegrateRatedBands(bandFixtureReadings(day, 1000), bandTestPlan(), day, sydney)++	require.True(t, ok)+	require.Len(t, bands, 4, "the free band splits the day into four rated segments")+	assert.Equal(t, []BandImportAttr{+		{Start: "00:00", End: "01:00", Kwh: 1.0},+		{Start: "01:00", End: "06:00", Kwh: 5.0},+		{Start: "06:00", End: "10:00", Kwh: 4.0},+		{Start: "15:00", End: "24:00", Kwh: 9.0},+	}, bands)+	// 24 h day minus the 5 h free window = 19 kWh at 1 kW.+	assert.InDelta(t, 19.0, total, 0.01)+}++// The free band's import is deliberately excluded: the flux-offpeak row owns+// that quantity (Q31), and capturing it twice is what backfill repairs would+// desynchronise.+func TestIntegrateRatedBands_ExcludesFreeWindow(t *testing.T) {+	t.Parallel()+	sydney, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)+	day := time.Date(2026, 8, 12, 0, 0, 0, 0, sydney)++	bands, _, ok := IntegrateRatedBands(bandFixtureReadings(day, 1000), bandTestPlan(), day, sydney)++	require.True(t, ok)+	for _, b := range bands {+		assert.False(t, b.Start == "10:00" && b.End == "15:00", "the free band must not be stored")+	}+}++// A plan with no free band leaves the whole day rated, so the bands tile+// 00:00–24:00 and the total is the day's entire grid import.+func TestIntegrateRatedBands_WholeDayWhenNoFreeBand(t *testing.T) {+	t.Parallel()+	sydney, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)+	day := time.Date(2026, 8, 12, 0, 0, 0, 0, sydney)+	rated := plan.Plan{ID: "flat", StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05}++	bands, total, ok := IntegrateRatedBands(bandFixtureReadings(day, 1000), rated, day, sydney)++	require.True(t, ok)+	require.Len(t, bands, 1)+	assert.Equal(t, "00:00", bands[0].Start)+	assert.Equal(t, "24:00", bands[0].End)+	assert.InDelta(t, 24.0, total, 0.01)+}++// AC 3.8: on a DST day the bands follow local wall-clock time and still sum to+// the day's total — which only holds because the boundaries come from+// plan.SegmentBounds rather than midnight-plus-elapsed arithmetic.+func TestIntegrateRatedBands_DSTDaySumsToWholeDay(t *testing.T) {+	t.Parallel()+	sydney, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)++	tests := map[string]struct {+		day     time.Time+		wantKwh float64 // hours of rated time at 1 kW+	}{+		// 2026-10-04: DST start, 02:00 → 03:00 skipped. A 23-hour day, of+		// which the 10:00–15:00 free window still removes 5 wall-clock hours,+		// but 01:00–06:00 spans only 4 real hours.+		"dst start (23h day)": {day: time.Date(2026, 10, 4, 0, 0, 0, 0, sydney), wantKwh: 18.0},+		// 2026-04-05: DST end, 03:00 repeated. A 25-hour day.+		"dst end (25h day)": {day: time.Date(2026, 4, 5, 0, 0, 0, 0, sydney), wantKwh: 20.0},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			readings := bandFixtureReadings(tc.day, 1000)+			bands, total, ok := IntegrateRatedBands(readings, bandTestPlan(), tc.day, sydney)+			require.True(t, ok)++			// The bands' wall-clock geometry is the same on any day...+			assert.Equal(t, "00:00", bands[0].Start)+			assert.Equal(t, "24:00", bands[len(bands)-1].End)+			// ...but the energy follows real elapsed time.+			assert.InDelta(t, tc.wantKwh, total, 0.05)++			// The whole-day integral over the same readings, less the free+			// window, must equal the band total — the invariant that breaks+			// if any boundary is computed by elapsed-minute arithmetic.+			dayStart := tc.day+			dayEnd := tc.day.AddDate(0, 0, 1)+			whole, wholeOK := derivedstats.IntegrateOffpeakDeltas(readings, dayStart.Unix(), dayEnd.Unix())+			require.True(t, wholeOK)+			freeStart := time.Date(tc.day.Year(), tc.day.Month(), tc.day.Day(), 10, 0, 0, 0, sydney)+			freeEnd := time.Date(tc.day.Year(), tc.day.Month(), tc.day.Day(), 15, 0, 0, 0, sydney)+			free, freeOK := derivedstats.IntegrateOffpeakDeltas(readings, freeStart.Unix(), freeEnd.Unix())+			require.True(t, freeOK)+			assert.InDelta(t, whole.GridImportKwh-free.GridImportKwh, total, 0.05)+		})+	}+}++// A partially known split is unavailable, not partially usable (AC 3.6): one+// unusable segment discards the whole result so no caller can persist half a+// day's bands.+func TestIntegrateRatedBands_UnusableSegmentDiscardsSplit(t *testing.T) {+	t.Parallel()+	sydney, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)+	day := time.Date(2026, 8, 12, 0, 0, 0, 0, sydney)++	// Readings only from 06:00 onward: the 00:00–01:00 and 01:00–06:00+	// segments have nothing to integrate.+	full := bandFixtureReadings(day, 1000)+	late := full[6*60:]++	_, _, ok := IntegrateRatedBands(late, bandTestPlan(), day, sydney)+	assert.False(t, ok, "a segment with no readings makes the whole split unavailable")+}++func TestIntegrateRatedBands_NoRatedSegments(t *testing.T) {+	t.Parallel()+	sydney, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)+	day := time.Date(2026, 8, 12, 0, 0, 0, 0, sydney)+	savings := 0.35+	allFree := plan.Plan{+		ID: "all-free", StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+		Windows:        []plan.Window{{Start: "00:00", End: "24:00", Free: true}},+		SavingsRefRate: &savings,+	}++	_, _, ok := IntegrateRatedBands(bandFixtureReadings(day, 1000), allFree, day, sydney)+	assert.False(t, ok, "a plan with no rated band has no split to capture")+}
internal/dynamo/bandimports.go Added +59 / -0
diff --git a/internal/dynamo/bandimports.go b/internal/dynamo/bandimports.gonew file mode 100644index 0000000..4299529--- /dev/null+++ b/internal/dynamo/bandimports.go@@ -0,0 +1,59 @@+package dynamo++import (+	"time"++	"github.com/ArjenSchwarz/flux/internal/derivedstats"+	"github.com/ArjenSchwarz/flux/internal/plan"+)++// IntegrateRatedBands computes one day's rated-band grid import from readings.+//+// It lives here, between the two leaf packages, because three writers need the+// identical number: the poller's day-close capture, the backfill CLI's repair,+// and — through the values they persist — every screen that prices the day.+// A second implementation anywhere would be a second answer (Data Consistency).+//+// Only the rated segments are integrated. Free-window import belongs to the+// flux-offpeak row, which owns that quantity exclusively (Q31).+//+// Segment boundaries come from plan.SegmentBounds, which resolves them on the+// day's wall clock. Deriving them by adding elapsed minutes to midnight is an+// hour off on a DST day, which is exactly the latent bug the peak block used+// to carry — routing both through this helper retires it.+//+// totalKwh is the sum of the raw per-segment integrals rounded once, not the+// sum of the rounded entries: rounding first would let per-band error+// accumulate into a total that disagrees with the sum of what is displayed.+//+// ok is false when the plan has no rated segments, or when any rated segment+// fails the integrator's usability gate. A partially known split counts as+// unavailable (AC 3.6), so callers must not persist a partial result.+func IntegrateRatedBands(+	readings []derivedstats.Reading,+	p plan.Plan,+	day time.Time,+	loc *time.Location,+) (bands []BandImportAttr, totalKwh float64, ok bool) {+	rated := plan.RatedSegments(p)+	if len(rated) == 0 {+		return nil, 0, false+	}++	out := make([]BandImportAttr, 0, len(rated))+	var rawTotal float64+	for _, seg := range rated {+		startUnix, endUnix := plan.SegmentBounds(seg, day, loc)+		deltas, segOK := derivedstats.IntegrateOffpeakDeltas(readings, startUnix, endUnix)+		if !segOK {+			return nil, 0, false+		}+		rawTotal += deltas.GridImportKwh+		out = append(out, BandImportAttr{+			Start: seg.Start,+			End:   seg.End,+			Kwh:   derivedstats.RoundEnergy(deltas.GridImportKwh),+		})+	}+	return out, derivedstats.RoundEnergy(rawTotal), true+}
internal/dynamo/derived_store_test.go Modified +2 / -0
diff --git a/internal/dynamo/derived_store_test.go b/internal/dynamo/derived_store_test.goindex 71cd832..cb194e2 100644--- a/internal/dynamo/derived_store_test.go+++ b/internal/dynamo/derived_store_test.go@@ -182,6 +182,8 @@ func TestWriteDailyEnergy_StructTagCoverage(t *testing.T) { 		"derivedStatsComputedAt": true, 		"peakGridImportKwh":      true, 		"peakComputedAt":         true,+		"bandImports":            true,+		"bandsComputedAt":        true, 	} 	keyTags := map[string]bool{ 		"sysSn": true,
internal/dynamo/dynamostore.go Modified +13 / -3
diff --git a/internal/dynamo/dynamostore.go b/internal/dynamo/dynamostore.goindex c935900..4e3187c 100644--- a/internal/dynamo/dynamostore.go+++ b/internal/dynamo/dynamostore.go@@ -104,13 +104,13 @@ func (s *DynamoStore) WriteDailyEnergy(ctx context.Context, item DailyEnergyItem func (s *DynamoStore) UpdateDailyEnergyDerived(ctx context.Context, sysSn, date string, stats DerivedStats) error { 	tableName := s.tables.DailyEnergy -	// The derivedStats group and the peak group have independent lifecycles+	// The derivedStats, peak, and band groups have independent lifecycles 	// (peak-from-readings Decision 3). Each is written only when its own sentinel is non-empty, 	// so the summarisation pass can fill peak on a row that already has derived-	// stats — and vice versa — without clobbering the other group with zero+	// stats — and vice versa — without clobbering the other groups with zero 	// values. At least one group is always present in a real call; an empty 	// stats produces a no-op write guarded below.-	sets := make([]string, 0, 6)+	sets := make([]string, 0, 8) 	values := map[string]types.AttributeValue{}  	if stats.DerivedStatsComputedAt != "" {@@ -143,6 +143,16 @@ func (s *DynamoStore) UpdateDailyEnergyDerived(ctx context.Context, sysSn, date 		values[":pkts"] = &types.AttributeValueMemberS{Value: stats.PeakComputedAt} 	} +	if stats.BandsComputedAt != "" {+		bandsAV, err := attributevalue.Marshal(stats.BandImports)+		if err != nil {+			return fmt.Errorf("marshal bandImports (sysSn=%s, date=%s): %w", sysSn, date, err)+		}+		sets = append(sets, "bandImports = :bi", "bandsComputedAt = :bits")+		values[":bi"] = bandsAV+		values[":bits"] = &types.AttributeValueMemberS{Value: stats.BandsComputedAt}+	}+ 	if len(sets) == 0 { 		// Nothing to write — neither sentinel set. Treat as a no-op rather 		// than issuing an empty UpdateExpression (which DynamoDB rejects).
internal/dynamo/models.go Modified +66 / -0
diff --git a/internal/dynamo/models.go b/internal/dynamo/models.goindex 5cf1446..b555dcc 100644--- a/internal/dynamo/models.go+++ b/internal/dynamo/models.go@@ -5,6 +5,7 @@ import ( 	"time"  	"github.com/ArjenSchwarz/flux/internal/alphaess"+	"github.com/ArjenSchwarz/flux/internal/plan" )  const ttl30Days = 30 * 24 * time.Hour@@ -63,6 +64,26 @@ type DailyEnergyItem struct { 	// peakGridImportKwh. The divergence is intentional and not worth a rename. 	PeakGridImportKwh *float64 `dynamodbav:"peakGridImportKwh,omitempty"` 	PeakComputedAt    string   `dynamodbav:"peakComputedAt,omitempty"`++	// BandImports is the day's per-band grid import, captured at day close so+	// banded costs survive the 30-day readings TTL (Q13). It holds the RATED+	// segments only — free-window import lives on the flux-offpeak row, which+	// owns that quantity exclusively (Q31). Each entry snapshots the geometry+	// it was captured under (Q23), so a later window edit shows up as a+	// mismatch instead of silently mispricing the day.+	//+	// Gated on its own BandsComputedAt sentinel, third in the group set+	// UpdateDailyEnergyDerived writes independently. Absent when the+	// integrator's usability gate fails for any rated segment.+	BandImports     []BandImportAttr `dynamodbav:"bandImports,omitempty"`+	BandsComputedAt string           `dynamodbav:"bandsComputedAt,omitempty"`+}++// BandImportAttr is the storage shape for one rated band's import energy.+type BandImportAttr struct {+	Start string  `dynamodbav:"start"` // HH:MM, Sydney local+	End   string  `dynamodbav:"end"`   // HH:MM, may be "24:00"+	Kwh   float64 `dynamodbav:"kwh"` }  // DailyUsageAttr is the storage shape for derivedstats.DailyUsage.@@ -116,6 +137,14 @@ type DerivedStats struct { 	// sentinel is non-empty. 	PeakGridImportKwh *float64 	PeakComputedAt    string++	// BandImports / BandsComputedAt carry the per-band split. Third group with+	// its own lifecycle, same contract as the peak pair above: written only+	// when BandsComputedAt is non-empty, and a nil BandImports with the+	// sentinel set records "attempted, unavailable" so the row is not retried+	// every hour.+	BandImports     []BandImportAttr+	BandsComputedAt string }  // DailyPowerItem represents a row in the flux-daily-power table.@@ -181,8 +210,45 @@ type OffpeakItem struct { 	IntegrationSampleCount  int    `dynamodbav:"integrationSampleCount,omitempty"` 	IntegrationSkippedPairs int    `dynamodbav:"integrationSkippedPairs,omitempty"` 	IntegratedAt            string `dynamodbav:"integratedAt,omitempty"` // RFC3339 UTC++	// WindowStart / WindowEnd snapshot the free window this row was+	// integrated under, so a later plan edit that moves the window is+	// detectable as a mismatch rather than silently repricing the day. Absent+	// on pre-feature rows — see Geometry.+	WindowStart string `dynamodbav:"windowStart,omitempty"` // HH:MM, Sydney local+	WindowEnd   string `dynamodbav:"windowEnd,omitempty"` } +// PlanRow converts the row to the cost domain's view of it. plan.OffpeakRow+// mirrors this type field for field (Decision 7), so the conversion is the+// single place the two shapes meet — the migration tool and the API both go+// through it rather than assembling the domain value by hand.+func (o OffpeakItem) PlanRow() plan.OffpeakRow {+	return plan.OffpeakRow{+		GridImportKwh: o.GridUsageKwh,+		WindowStart:   o.WindowStart,+		WindowEnd:     o.WindowEnd,+		IntegratedAt:  o.IntegratedAt,+		SampleCount:   o.IntegrationSampleCount,+	}+}++// Geometry returns the free window the row's deltas were computed over,+// substituting the pre-feature window when the row carries no snapshot.+//+// Both this and Usable delegate to plan.OffpeakRow: tier-1 costing turns on+// exactly these two rules, and the shared cost vectors pin them, so a second+// copy here would be a second answer to whether a day can be priced from its+// stored split.+func (o OffpeakItem) Geometry() (start, end string) { return o.PlanRow().Geometry() }++// Usable reports whether the row's deltas are a real measurement. A row+// integrated from readings but with no samples in the window is a zero-delta+// artifact, not a measured zero, so it cannot price a free band. Rows+// predating the integration path (no IntegratedAt) are snapshot deltas and+// remain usable.+func (o OffpeakItem) Usable() bool { return o.PlanRow().Usable() }+ // NewReadingItem transforms AlphaESS power data into a DynamoDB reading item. func NewReadingItem(serial string, data *alphaess.PowerData, now time.Time) ReadingItem { 	return ReadingItem{
internal/dynamo/pricing_atomicity_test.go Modified +21 / -9
diff --git a/internal/dynamo/pricing_atomicity_test.go b/internal/dynamo/pricing_atomicity_test.goindex f694ee7..bb20141 100644--- a/internal/dynamo/pricing_atomicity_test.go+++ b/internal/dynamo/pricing_atomicity_test.go@@ -5,6 +5,7 @@ import ( 	"errors" 	"testing" +	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" 	"github.com/aws/aws-sdk-go-v2/service/dynamodb" 	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types" 	"github.com/stretchr/testify/assert"@@ -36,8 +37,19 @@ func canceledWith(reasons []types.CancellationReason) error { // newAtomicityMock returns a *mockPricingAPI configured so every // TransactWriteItems call fails with the supplied error. Used by the // failure-shape tests to inject a deterministic CancellationReason set.+// The GetItem stub serves the band-shape closing row ReplaceOpenEnded reads+// before it commits; without it every case would fail on the legacy-shape+// guard instead of reaching the transaction it is trying to exercise. func newAtomicityMock(transactErr error) *mockPricingAPI { 	return &mockPricingAPI{+		getItemFn: func(_ context.Context, params *dynamodb.GetItemInput) (*dynamodb.GetItemOutput, error) {+			id := params.Key["pricingId"].(*types.AttributeValueMemberS).Value+			av, err := attributevalue.MarshalMap(bandPricingItem(id, "2026-01-01", nil))+			if err != nil {+				return nil, err+			}+			return &dynamodb.GetItemOutput{Item: av}, nil+		}, 		transactWriteFn: func(_ context.Context, _ *dynamodb.TransactWriteItemsInput) (*dynamodb.TransactWriteItemsOutput, error) { 			return nil, transactErr 		},@@ -50,7 +62,7 @@ func TestPricingAtomicity_ReplaceOpenEnded_SentinelRace(t *testing.T) { 	store := NewDynamoPricingStore(mock, pricingTestTable())  	err := store.ReplaceOpenEnded(context.Background(), "open-id", "2026-06-30", "2026-05-24T10:00:00Z",-		PricingItem{PricingID: "new-id", StartDate: "2026-07-01", PeakRate: 0.3})+		PricingItem{PricingID: "new-id", StartDate: "2026-07-01", DefaultRate: 0.3}) 	require.Error(t, err) 	assert.ErrorIs(t, err, ErrPricingConcurrentWrite, 		"sentinel position must map to concurrent_open_ended_write")@@ -62,7 +74,7 @@ func TestPricingAtomicity_ReplaceOpenEnded_ClosingRowRace(t *testing.T) { 	store := NewDynamoPricingStore(mock, pricingTestTable())  	err := store.ReplaceOpenEnded(context.Background(), "open-id", "2026-06-30", "2026-05-24T10:00:00Z",-		PricingItem{PricingID: "new-id", StartDate: "2026-07-01", PeakRate: 0.3})+		PricingItem{PricingID: "new-id", StartDate: "2026-07-01", DefaultRate: 0.3}) 	require.Error(t, err) 	assert.ErrorIs(t, err, ErrPricingConcurrentWrite, 		"closing-row position must map to concurrent_open_ended_write")@@ -74,7 +86,7 @@ func TestPricingAtomicity_ReplaceOpenEnded_UUIDCollision(t *testing.T) { 	store := NewDynamoPricingStore(mock, pricingTestTable())  	err := store.ReplaceOpenEnded(context.Background(), "open-id", "2026-06-30", "2026-05-24T10:00:00Z",-		PricingItem{PricingID: "new-id", StartDate: "2026-07-01", PeakRate: 0.3})+		PricingItem{PricingID: "new-id", StartDate: "2026-07-01", DefaultRate: 0.3}) 	require.Error(t, err) 	assert.ErrorIs(t, err, ErrPricingUUIDCollision, 		"new-row position must map to uuid_collision so the caller retries")@@ -88,7 +100,7 @@ func TestPricingAtomicity_EmptyReasonsFallsThroughTo500(t *testing.T) { 	store := NewDynamoPricingStore(mock, pricingTestTable())  	err := store.ReplaceOpenEnded(context.Background(), "open-id", "2026-06-30", "2026-05-24T10:00:00Z",-		PricingItem{PricingID: "new-id", StartDate: "2026-07-01", PeakRate: 0.3})+		PricingItem{PricingID: "new-id", StartDate: "2026-07-01", DefaultRate: 0.3}) 	require.Error(t, err) 	assert.NotErrorIs(t, err, ErrPricingConcurrentWrite, 		"empty Reasons[] must not masquerade as a concurrent_open_ended_write")@@ -103,7 +115,7 @@ func TestPricingAtomicity_PutOpenEnded_SentinelRace(t *testing.T) { 	store := NewDynamoPricingStore(mock, pricingTestTable())  	err := store.PutPricing(context.Background(),-		PricingItem{PricingID: "new-open", StartDate: "2026-07-01", PeakRate: 0.3}, nil)+		PricingItem{PricingID: "new-open", StartDate: "2026-07-01", DefaultRate: 0.3}, nil) 	require.Error(t, err) 	assert.ErrorIs(t, err, ErrPricingConcurrentWrite) }@@ -116,7 +128,7 @@ func TestPricingAtomicity_UpdateClosedToOpen_SentinelRace(t *testing.T) { 	// item.EndDate == nil and prevOpenEndedID == nil triggers the 	// closed→open transition in production. 	err := store.UpdatePricing(context.Background(),-		PricingItem{PricingID: "p-1", StartDate: "2026-01-01", PeakRate: 0.3}, nil)+		PricingItem{PricingID: "p-1", StartDate: "2026-01-01", DefaultRate: 0.3}, nil) 	require.Error(t, err) 	assert.ErrorIs(t, err, ErrPricingConcurrentWrite) }@@ -131,7 +143,7 @@ func TestPricingAtomicity_UpdateOpenToClosed_SentinelRace(t *testing.T) { 	// item.EndDate set + prevOpenEndedID matches item.PricingID — was 	// open, now closed. 	err := store.UpdatePricing(context.Background(),-		PricingItem{PricingID: openID, StartDate: "2026-01-01", EndDate: &end, PeakRate: 0.3}, &openID)+		PricingItem{PricingID: openID, StartDate: "2026-01-01", EndDate: &end, DefaultRate: 0.3}, &openID) 	require.Error(t, err) 	assert.ErrorIs(t, err, ErrPricingConcurrentWrite) }@@ -144,7 +156,7 @@ func TestPricingAtomicity_UpdateOpenToOpen_SentinelRace(t *testing.T) {  	openID := "p-1" 	err := store.UpdatePricing(context.Background(),-		PricingItem{PricingID: openID, StartDate: "2026-01-01", PeakRate: 0.3}, &openID)+		PricingItem{PricingID: openID, StartDate: "2026-01-01", DefaultRate: 0.3}, &openID) 	require.Error(t, err) 	assert.ErrorIs(t, err, ErrPricingConcurrentWrite) }@@ -172,7 +184,7 @@ func TestPricingAtomicity_FirstWriteSentinelCreationRace(t *testing.T) { 	// mapping treats a sentinel ConditionalCheckFailed at index 0 the 	// same way regardless of which clause fired. 	err := store.PutPricing(context.Background(),-		PricingItem{PricingID: "new-open", StartDate: "2026-07-01", PeakRate: 0.3}, nil)+		PricingItem{PricingID: "new-open", StartDate: "2026-07-01", DefaultRate: 0.3}, nil) 	require.Error(t, err) 	assert.ErrorIs(t, err, ErrPricingConcurrentWrite) }
internal/dynamo/pricing_legacy_test.go Added +282 / -0
diff --git a/internal/dynamo/pricing_legacy_test.go b/internal/dynamo/pricing_legacy_test.gonew file mode 100644index 0000000..e06d892--- /dev/null+++ b/internal/dynamo/pricing_legacy_test.go@@ -0,0 +1,282 @@+package dynamo++import (+	"context"+	"testing"++	"github.com/ArjenSchwarz/flux/internal/plan"+	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// legacyRow builds the raw attribute map of a pre-migration three-rate row.+// Built as a raw map rather than by marshalling a struct because that is+// exactly the shape the read path has to recognise.+func legacyRow(id, startDate string, endDate *string) map[string]types.AttributeValue {+	av := map[string]types.AttributeValue{+		"pricingId":          &types.AttributeValueMemberS{Value: id},+		"startDate":          &types.AttributeValueMemberS{Value: startDate},+		"peakRate":           &types.AttributeValueMemberN{Value: "0.2873"},+		"feedInRate":         &types.AttributeValueMemberN{Value: "0.05"},+		"offPeakSavingsRate": &types.AttributeValueMemberN{Value: "0.15"},+		"createdAt":          &types.AttributeValueMemberS{Value: "2026-05-23T10:00:00Z"},+		"updatedAt":          &types.AttributeValueMemberS{Value: "2026-05-23T10:00:00Z"},+	}+	if endDate != nil {+		av["endDate"] = &types.AttributeValueMemberS{Value: *endDate}+	}+	return av+}++// TestIsLegacyPricingRow pins detection to the raw attribute map. Detecting+// via a decoded struct is not an option: attributevalue silently drops+// unknown attributes, so a legacy row decodes into the band shape as a+// zero-rate plan with no windows rather than as something recognisably wrong.+func TestIsLegacyPricingRow(t *testing.T) {+	t.Parallel()+	bandRow, err := attributevalue.MarshalMap(bandPricingItem("p-1", "2026-01-01", nil))+	require.NoError(t, err)++	tests := map[string]struct {+		row  map[string]types.AttributeValue+		want bool+	}{+		"legacy row":     {row: legacyRow("p-1", "2026-01-01", nil), want: true},+		"band-shape row": {row: bandRow, want: false},+		"empty map":      {row: map[string]types.AttributeValue{}, want: false},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			assert.Equal(t, tc.want, IsLegacyPricingRow(tc.row))+		})+	}+}++// TestTransformLegacyPricing covers AC 5.1: the legacy period maps to a free+// band matching the window its historical data was computed under, a default+// rate carrying the former flat rate, the unchanged feed-in rate, and a+// savings reference rate equal to the former off-peak savings rate.+func TestTransformLegacyPricing(t *testing.T) {+	t.Parallel()+	legacyEnd := "2026-07-31"+	got, err := TransformLegacyPricing(LegacyPricingItem{+		PricingID:          "p-1",+		StartDate:          "2026-01-01",+		EndDate:            &legacyEnd,+		PeakRate:           0.2873,+		FeedInRate:         0.05,+		OffPeakSavingsRate: 0.15,+		CreatedAt:          "2026-05-23T10:00:00Z",+		UpdatedAt:          "2026-05-23T10:00:00Z",+	})+	require.NoError(t, err)++	savings := 0.15+	// The legacy inclusive end 2026-07-31 becomes the exclusive 2026-08-01,+	// so the period still prices through 31 July and no day is gained or+	// lost (AC 5.2).+	wantEnd := "2026-08-01"+	assert.Equal(t, PricingItem{+		PricingID:            "p-1",+		StartDate:            "2026-01-01",+		EndDate:              &wantEnd,+		DefaultRate:          0.2873,+		Windows:              []PricingWindow{{Start: "11:00", End: "14:00", Free: true}},+		FeedInRate:           0.05,+		SavingsReferenceRate: &savings,+		CreatedAt:            "2026-05-23T10:00:00Z",+		UpdatedAt:            "2026-05-23T10:00:00Z",+	}, got)+}++// TestTransformLegacyPricingOpenEnded pins that an open-ended legacy row stays+// open-ended — there is no end date to shift.+func TestTransformLegacyPricingOpenEnded(t *testing.T) {+	t.Parallel()+	got, err := TransformLegacyPricing(LegacyPricingItem{+		PricingID: "p-1", StartDate: "2026-01-01",+		PeakRate: 0.3, FeedInRate: 0.05, OffPeakSavingsRate: 0.1,+		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",+	})+	require.NoError(t, err)+	assert.Nil(t, got.EndDate)+}++// TestTransformLegacyPricingEndDateShift covers the inclusive → exclusive+// mapping across month, year, and leap-day boundaries.+func TestTransformLegacyPricingEndDateShift(t *testing.T) {+	t.Parallel()+	tests := map[string]struct{ legacy, want string }{+		"mid month":       {legacy: "2026-03-14", want: "2026-03-15"},+		"end of month":    {legacy: "2026-04-30", want: "2026-05-01"},+		"end of year":     {legacy: "2026-12-31", want: "2027-01-01"},+		"leap day":        {legacy: "2028-02-29", want: "2028-03-01"},+		"end of february": {legacy: "2026-02-28", want: "2026-03-01"},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			end := tc.legacy+			got, err := TransformLegacyPricing(LegacyPricingItem{+				PricingID: "p-1", StartDate: "2020-01-01", EndDate: &end,+				PeakRate: 0.3, FeedInRate: 0.05, OffPeakSavingsRate: 0.1,+			})+			require.NoError(t, err)+			require.NotNil(t, got.EndDate)+			assert.Equal(t, tc.want, *got.EndDate)+		})+	}+}++// TestTransformLegacyPricingRejectsMalformedEndDate pins that an unparseable+// end date is an error rather than a silently dropped one — the migration+// must abort, not quietly open-end a closed period.+func TestTransformLegacyPricingRejectsMalformedEndDate(t *testing.T) {+	t.Parallel()+	end := "31-12-2026"+	_, err := TransformLegacyPricing(LegacyPricingItem{+		PricingID: "p-1", StartDate: "2026-01-01", EndDate: &end,+	})+	require.Error(t, err)+}++// TestTransformedLegacyPlanPreservesTheHistoricalWindow ties the transform+// back to the domain: the resulting plan validates, prices the same days, and+// exposes the 11:00–14:00 window historical off-peak values were computed+// under.+func TestTransformedLegacyPlanPreservesTheHistoricalWindow(t *testing.T) {+	t.Parallel()+	legacyEnd := "2026-07-31"+	item, err := TransformLegacyPricing(LegacyPricingItem{+		PricingID: "p-1", StartDate: "2026-01-01", EndDate: &legacyEnd,+		PeakRate: 0.2873, FeedInRate: 0.05, OffPeakSavingsRate: 0.15,+	})+	require.NoError(t, err)++	p := item.Plan()+	assert.Empty(t, p.Validate())+	assert.True(t, p.Covers("2026-07-31"), "the legacy inclusive end date must still be priced")+	assert.False(t, p.Covers("2026-08-01"), "the exclusive end date must not be priced")++	start, end, ok := p.FreeWindowMinutes()+	require.True(t, ok)+	assert.Equal(t, 11*60, start)+	assert.Equal(t, 14*60, end)+}++// TestListPricingTransformsLegacyRows covers Q28: until the migration runs,+// the read path converts legacy rows so a band-aware poller or Lambda+// deployed first still resolves windows and serves plans correctly.+func TestListPricingTransformsLegacyRows(t *testing.T) {+	t.Parallel()+	api := newInMemoryPricingAPI()+	store := NewDynamoPricingStore(api, pricingTestTable())++	legacyEnd := "2026-07-31"+	api.items["p-legacy"] = legacyRow("p-legacy", "2026-01-01", &legacyEnd)+	newItem := bandPricingItem("p-band", "2026-08-01", nil)+	require.NoError(t, store.PutPricing(context.Background(), newItem, nil))++	got, err := store.ListPricing(context.Background())+	require.NoError(t, err)+	require.Len(t, got, 2)++	assert.Equal(t, "p-legacy", got[0].PricingID)+	assert.Equal(t, 0.2873, got[0].DefaultRate)+	assert.Equal(t, []PricingWindow{{Start: "11:00", End: "14:00", Free: true}}, got[0].Windows)+	require.NotNil(t, got[0].SavingsReferenceRate)+	assert.Equal(t, 0.15, *got[0].SavingsReferenceRate)+	require.NotNil(t, got[0].EndDate)+	assert.Equal(t, "2026-08-01", *got[0].EndDate)++	assert.Equal(t, newItem, got[1], "band-shape rows pass through unchanged")+}++// TestListPricingLeavesTheSentinelAlone pins that the sentinel row is never+// mistaken for a pricing row by either the legacy detector or the transform.+func TestListPricingLeavesTheSentinelAlone(t *testing.T) {+	t.Parallel()+	api := newInMemoryPricingAPI()+	store := NewDynamoPricingStore(api, pricingTestTable())++	openID := "p-legacy"+	sentinel := PricingSentinel{PricingID: PricingSentinelID, OpenEndedID: &openID, UpdatedAt: "2026-05-23T10:00:00Z"}+	av, err := attributevalue.MarshalMap(sentinel)+	require.NoError(t, err)+	api.items[PricingSentinelID] = av+	api.items["p-legacy"] = legacyRow("p-legacy", "2026-01-01", nil)++	got, err := store.ListPricing(context.Background())+	require.NoError(t, err)+	require.Len(t, got, 1)+	assert.Equal(t, "p-legacy", got[0].PricingID)++	gotSentinel, err := store.GetSentinel(context.Background())+	require.NoError(t, err)+	require.NotNil(t, gotSentinel)+	assert.Equal(t, sentinel, *gotSentinel)+}++// TestGetPricingTransformsLegacyRow covers the single-row read path, which+// needs the same conversion as the list path.+func TestGetPricingTransformsLegacyRow(t *testing.T) {+	t.Parallel()+	api := newInMemoryPricingAPI()+	store := NewDynamoPricingStore(api, pricingTestTable())+	api.items["p-legacy"] = legacyRow("p-legacy", "2026-01-01", nil)++	got, err := store.GetPricing(context.Background(), "p-legacy")+	require.NoError(t, err)+	require.NotNil(t, got)+	assert.Equal(t, 0.2873, got.DefaultRate)+	assert.Equal(t, []PricingWindow{{Start: "11:00", End: "14:00", Free: true}}, got.Windows)+	assert.Nil(t, got.EndDate)+}++// TestPlansFromItems pins the conversion the poller and Lambda consume:+// storage rows in, domain plans out, ready for PlanFor / FreeWindow.+func TestPlansFromItems(t *testing.T) {+	t.Parallel()+	closing := bandPricingItem("p-old", "2026-01-01", strPtr("2026-08-01"))+	successor := bandPricingItem("p-new", "2026-08-01", nil)+	successor.Windows = []PricingWindow{+		{Start: "10:00", End: "15:00", Free: true},+		{Start: "01:00", End: "06:00", Rate: floatPtr(0.28)},+	}++	plans := PlansFromItems([]PricingItem{closing, successor})+	require.Len(t, plans, 2)++	got, ok := plan.PlanFor(plans, "2026-08-01")+	require.True(t, ok)+	assert.Equal(t, "p-new", got.ID, "AC 2.2: the switch day belongs to the successor")++	start, end, ok := plan.FreeWindow(plans, "2026-08-01")+	require.True(t, ok)+	assert.Equal(t, 10*60, start)+	assert.Equal(t, 15*60, end)++	start, end, ok = plan.FreeWindow(plans, "2026-07-31")+	require.True(t, ok)+	assert.Equal(t, 11*60, start)+	assert.Equal(t, 14*60, end)+}++// TestPricingItemPlanCarriesWindowRates pins that a rated window's rate+// survives the storage → domain conversion, and that a free window's absent+// rate becomes the zero the domain ignores.+func TestPricingItemPlanCarriesWindowRates(t *testing.T) {+	t.Parallel()+	item := bandPricingItem("p-1", "2026-01-01", nil)+	item.Windows = []PricingWindow{+		{Start: "10:00", End: "15:00", Free: true},+		{Start: "01:00", End: "06:00", Rate: floatPtr(0.28)},+	}+	assert.Equal(t, []plan.Window{+		{Start: "10:00", End: "15:00", Free: true},+		{Start: "01:00", End: "06:00", Rate: 0.28},+	}, item.Plan().Windows)+}
internal/dynamo/pricing_succession_test.go Added +120 / -0
diff --git a/internal/dynamo/pricing_succession_test.go b/internal/dynamo/pricing_succession_test.gonew file mode 100644index 0000000..cd27d47--- /dev/null+++ b/internal/dynamo/pricing_succession_test.go@@ -0,0 +1,120 @@+package dynamo++import (+	"context"+	"testing"++	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// TestReplaceOpenEnded_SameDaySuccession covers AC 2.2 under exclusive end+// dates: the closing row's end date is the successor's start date, literally+// the same string, and the switch day is priced by the successor.+func TestReplaceOpenEnded_SameDaySuccession(t *testing.T) {+	t.Parallel()+	api := newInMemoryPricingAPI()+	store := NewDynamoPricingStore(api, pricingTestTable())++	closing := bandPricingItem("p-old", "2026-01-01", nil)+	require.NoError(t, store.PutPricing(context.Background(), closing, nil))++	successor := bandPricingItem("p-new", "2026-08-01", nil)+	successor.Windows = []PricingWindow{+		{Start: "10:00", End: "15:00", Free: true},+		{Start: "01:00", End: "06:00", Rate: floatPtr(0.28)},+	}+	require.NoError(t, store.ReplaceOpenEnded(context.Background(),+		"p-old", successor.StartDate, "2026-07-01T10:00:00Z", successor))++	rows, err := store.ListPricing(context.Background())+	require.NoError(t, err)+	require.Len(t, rows, 2)++	require.NotNil(t, rows[0].EndDate)+	assert.Equal(t, "2026-08-01", *rows[0].EndDate,+		"the closing row's exclusive end date is the successor's start date, with no ±1 arithmetic")++	plans := PlansFromItems(rows)+	assert.True(t, plans[0].Covers("2026-07-31"), "the predecessor's last priced day is the switch day eve")+	assert.False(t, plans[0].Covers("2026-08-01"))+	assert.True(t, plans[1].Covers("2026-08-01"), "AC 2.2: the switch day belongs to the successor")+}++// TestReplaceOpenEnded_RejectsLegacyClosingRow covers Q32. The closing write+// is a partial UpdateItem, so patching a not-yet-migrated row would leave it+// still legacy-detected but carrying an exclusive end date — which the read+// transform and then the migration would each shift by a day. Rejecting is+// the guard; the cutover order already runs the migration first.+func TestReplaceOpenEnded_RejectsLegacyClosingRow(t *testing.T) {+	t.Parallel()+	api := newInMemoryPricingAPI()+	store := NewDynamoPricingStore(api, pricingTestTable())+	api.items["p-legacy"] = legacyRow("p-legacy", "2026-01-01", nil)++	err := store.ReplaceOpenEnded(context.Background(),+		"p-legacy", "2026-08-01", "2026-07-01T10:00:00Z",+		bandPricingItem("p-new", "2026-08-01", nil))++	require.ErrorIs(t, err, ErrPricingLegacyShape)+	assert.True(t, IsLegacyPricingRow(api.items["p-legacy"]),+		"the legacy row must be left exactly as it was")+	assert.NotContains(t, api.items, "p-new", "the successor must not be written")+}++// TestReplaceOpenEnded_MissingClosingRow pins that a vanished closing row is+// an error rather than a silent half-succession.+func TestReplaceOpenEnded_MissingClosingRow(t *testing.T) {+	t.Parallel()+	api := newInMemoryPricingAPI()+	store := NewDynamoPricingStore(api, pricingTestTable())++	err := store.ReplaceOpenEnded(context.Background(),+		"p-missing", "2026-08-01", "2026-07-01T10:00:00Z",+		bandPricingItem("p-new", "2026-08-01", nil))++	require.ErrorIs(t, err, ErrPricingConcurrentWrite)+	assert.NotContains(t, api.items, "p-new")+}++// TestReplaceOpenEnded_WritesTheClosingEndDateVerbatim pins that the store+// stores what it is handed. Deriving the closing date is the caller's job+// under the switch-day semantics, so no date arithmetic belongs here.+func TestReplaceOpenEnded_WritesTheClosingEndDateVerbatim(t *testing.T) {+	t.Parallel()+	var captured *dynamodb.TransactWriteItemsInput+	api := newInMemoryPricingAPI()+	store := NewDynamoPricingStore(&recordingPricingAPI{inMemoryPricingAPI: api, onTransact: func(in *dynamodb.TransactWriteItemsInput) {+		captured = in+	}}, pricingTestTable())+	require.NoError(t, store.PutPricing(context.Background(), bandPricingItem("p-old", "2026-01-01", nil), nil))++	require.NoError(t, store.ReplaceOpenEnded(context.Background(),+		"p-old", "2026-08-01", "2026-07-01T10:00:00Z",+		bandPricingItem("p-new", "2026-08-01", nil)))++	require.NotNil(t, captured)+	require.Len(t, captured.TransactItems, 3, "sentinel, closing row, new row")+	closing := captured.TransactItems[1].Update+	require.NotNil(t, closing)+	end, isString := closing.ExpressionAttributeValues[":end"].(*types.AttributeValueMemberS)+	require.True(t, isString)+	assert.Equal(t, "2026-08-01", end.Value)+}++// recordingPricingAPI wraps the in-memory fake to capture the transaction+// input while still applying it, so a test can assert on the request shape+// and the resulting state in the same run.+type recordingPricingAPI struct {+	*inMemoryPricingAPI+	onTransact func(*dynamodb.TransactWriteItemsInput)+}++func (r *recordingPricingAPI) TransactWriteItems(ctx context.Context, params *dynamodb.TransactWriteItemsInput, optFns ...func(*dynamodb.Options)) (*dynamodb.TransactWriteItemsOutput, error) {+	if r.onTransact != nil {+		r.onTransact(params)+	}+	return r.inMemoryPricingAPI.TransactWriteItems(ctx, params, optFns...)+}
internal/dynamo/pricing_test.go Modified +98 / -86
diff --git a/internal/dynamo/pricing_test.go b/internal/dynamo/pricing_test.goindex 4030cae..528dbf8 100644--- a/internal/dynamo/pricing_test.go+++ b/internal/dynamo/pricing_test.go@@ -5,6 +5,7 @@ import ( 	"encoding/json" 	"errors" 	"sort"+	"strings" 	"testing"  	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"@@ -18,16 +19,11 @@ import ( // client decodes into PricingPeriod. PricingID must serialise as "id" so // the Swift Identifiable conformance works. func TestPricingItemJSONWireShape(t *testing.T) {-	end := "2026-12-31"-	item := PricingItem{-		PricingID:          "pricing-1",-		StartDate:          "2026-01-01",-		EndDate:            &end,-		PeakRate:           0.2873,-		FeedInRate:         0.0500,-		OffPeakSavingsRate: 0.1500,-		CreatedAt:          "2026-05-23T10:00:00Z",-		UpdatedAt:          "2026-05-23T10:00:00Z",+	end := "2027-01-01"+	item := bandPricingItem("pricing-1", "2026-01-01", &end)+	item.Windows = []PricingWindow{+		{Start: "10:00", End: "15:00", Free: true},+		{Start: "01:00", End: "06:00", Rate: floatPtr(0.28)}, 	} 	encoded, err := json.Marshal(item) 	require.NoError(t, err)@@ -39,14 +35,20 @@ func TestPricingItemJSONWireShape(t *testing.T) { 	assert.NotContains(t, raw, "PricingID")  	expected := map[string]any{-		"id":                 "pricing-1",-		"startDate":          "2026-01-01",-		"endDate":            "2026-12-31",-		"peakRate":           0.2873,-		"feedInRate":         0.05,-		"offPeakSavingsRate": 0.15,-		"createdAt":          "2026-05-23T10:00:00Z",-		"updatedAt":          "2026-05-23T10:00:00Z",+		"id":        "pricing-1",+		"startDate": "2026-01-01",+		// Exclusive switch date (Decision 5): this period's last priced day+		// is 2026-12-31.+		"endDate":     "2027-01-01",+		"defaultRate": 0.2873,+		"windows": []any{+			map[string]any{"start": "10:00", "end": "15:00", "free": true},+			map[string]any{"start": "01:00", "end": "06:00", "free": false, "rate": 0.28},+		},+		"feedInRate":           0.05,+		"savingsReferenceRate": 0.15,+		"createdAt":            "2026-05-23T10:00:00Z",+		"updatedAt":            "2026-05-23T10:00:00Z", 	} 	for key, want := range expected { 		assert.Equal(t, want, raw[key], "wire shape key %q", key)@@ -54,27 +56,43 @@ func TestPricingItemJSONWireShape(t *testing.T) { 	assert.Len(t, raw, len(expected)) } -// TestPricingItemJSONOmitsAbsentEndDate verifies the open-ended period's-// nil end date is omitted on the wire so the Swift decoder sees-// endDate == nil rather than an empty string.-func TestPricingItemJSONOmitsAbsentEndDate(t *testing.T) {-	item := PricingItem{-		PricingID:          "pricing-1",-		StartDate:          "2026-01-01",-		PeakRate:           0.2873,-		FeedInRate:         0.0500,-		OffPeakSavingsRate: 0.1500,-		CreatedAt:          "2026-05-23T10:00:00Z",-		UpdatedAt:          "2026-05-23T10:00:00Z",-	}+// TestPricingItemJSONOmitsAbsentOptionals verifies the open-ended period's+// nil end date and a free-band-less plan's absent savings reference rate are+// omitted on the wire, so the Swift decoder sees nil rather than a zero value.+func TestPricingItemJSONOmitsAbsentOptionals(t *testing.T) {+	item := bandPricingItem("pricing-1", "2026-01-01", nil)+	item.Windows = nil+	item.SavingsReferenceRate = nil+ 	encoded, err := json.Marshal(item) 	require.NoError(t, err)  	var raw map[string]any 	require.NoError(t, json.Unmarshal(encoded, &raw)) 	assert.NotContains(t, raw, "endDate")+	assert.NotContains(t, raw, "savingsReferenceRate")+}++// bandPricingItem is the shared fixture for a band-shape row: the migrated+// form of the plan that ran before the switch (free 11:00–14:00, one flat+// rate otherwise).+func bandPricingItem(id, startDate string, endDate *string) PricingItem {+	return PricingItem{+		PricingID:            id,+		StartDate:            startDate,+		EndDate:              endDate,+		DefaultRate:          0.2873,+		Windows:              []PricingWindow{{Start: "11:00", End: "14:00", Free: true}},+		FeedInRate:           0.0500,+		SavingsReferenceRate: floatPtr(0.1500),+		CreatedAt:            "2026-05-23T10:00:00Z",+		UpdatedAt:            "2026-05-23T10:00:00Z",+	} } +func floatPtr(v float64) *float64 { return &v }+func strPtr(v string) *string     { return &v }+ // inMemoryPricingAPI is a hand-rolled fake that satisfies every DynamoDB // operation the pricing reader/writer needs: PutItem, GetItem, UpdateItem, // DeleteItem, Scan, and TransactWriteItems. Backed by a single map keyed@@ -116,18 +134,44 @@ func (m *inMemoryPricingAPI) UpdateItem(_ context.Context, params *dynamodb.Upda 			"pricingId": &types.AttributeValueMemberS{Value: id}, 		} 	}-	for k, v := range params.ExpressionAttributeValues {-		// :openEndedId / :updatedAt etc. — translate the placeholder back-		// to the column name by stripping the leading ":". Sufficient for-		// the simple UpdateExpression we emit in production.-		name := k[1:]-		if name == "null" {+	applyUpdateExpression(row, params.UpdateExpression, params.ExpressionAttributeValues)+	m.items[id] = row+	return &dynamodb.UpdateItemOutput{}, nil+}++// applyUpdateExpression applies a "REMOVE attr, … SET attr = :ph, …"+// expression to a row. Every update expression the pricing store emits is of+// that shape.+//+// The attribute name has to come from the expression, not from the+// placeholder: the store writes `SET endDate = :end`, so mapping `:end` to an+// attribute called "end" would leave the closing row's end date unset and+// silently pass a succession test that should fail.+func applyUpdateExpression(row map[string]types.AttributeValue, expr *string, values map[string]types.AttributeValue) {+	if expr == nil {+		return+	}+	remove, set := "", *expr+	if before, after, found := strings.Cut(set, " SET "); found && strings.HasPrefix(set, "REMOVE ") {+		remove, set = strings.TrimPrefix(before, "REMOVE "), after+	} else {+		set = strings.TrimPrefix(set, "SET ")+	}++	for attr := range strings.SplitSeq(remove, ",") {+		if attr = strings.TrimSpace(attr); attr != "" {+			delete(row, attr)+		}+	}+	for assignment := range strings.SplitSeq(set, ",") {+		attr, placeholder, found := strings.Cut(assignment, "=")+		if !found { 			continue 		}-		row[name] = v+		if v, ok := values[strings.TrimSpace(placeholder)]; ok {+			row[strings.TrimSpace(attr)] = v+		} 	}-	m.items[id] = row-	return &dynamodb.UpdateItemOutput{}, nil }  func (m *inMemoryPricingAPI) DeleteItem(_ context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {@@ -173,13 +217,7 @@ func (m *inMemoryPricingAPI) TransactWriteItems(_ context.Context, params *dynam 					"pricingId": &types.AttributeValueMemberS{Value: id}, 				} 			}-			for k, v := range item.Update.ExpressionAttributeValues {-				name := k[1:]-				if name == "null" {-					continue-				}-				row[name] = v-			}+			applyUpdateExpression(row, item.Update.UpdateExpression, item.Update.ExpressionAttributeValues) 			m.items[id] = row 		case item.Delete != nil: 			id := item.Delete.Key["pricingId"].(*types.AttributeValueMemberS).Value@@ -206,17 +244,7 @@ func TestPricingStore_PutAndGetClosedPeriodRoundTrip(t *testing.T) { 	api := newInMemoryPricingAPI() 	store := NewDynamoPricingStore(api, pricingTestTable()) -	end := "2026-12-31"-	want := PricingItem{-		PricingID:          "pricing-1",-		StartDate:          "2026-01-01",-		EndDate:            &end,-		PeakRate:           0.2873,-		FeedInRate:         0.0500,-		OffPeakSavingsRate: 0.1500,-		CreatedAt:          "2026-05-23T10:00:00Z",-		UpdatedAt:          "2026-05-23T10:00:00Z",-	}+	want := bandPricingItem("pricing-1", "2026-01-01", strPtr("2027-01-01")) 	require.NoError(t, store.PutPricing(context.Background(), want, nil))  	got, err := store.GetPricing(context.Background(), want.PricingID)@@ -229,12 +257,7 @@ func TestPricingStore_DeleteClosedPeriod(t *testing.T) { 	api := newInMemoryPricingAPI() 	store := NewDynamoPricingStore(api, pricingTestTable()) -	end := "2026-12-31"-	item := PricingItem{-		PricingID: "pricing-1", StartDate: "2026-01-01", EndDate: &end,-		PeakRate: 0.2873, FeedInRate: 0.0500, OffPeakSavingsRate: 0.1500,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}+	item := bandPricingItem("pricing-1", "2026-01-01", strPtr("2027-01-01")) 	require.NoError(t, store.PutPricing(context.Background(), item, nil)) 	require.NoError(t, store.DeletePricing(context.Background(), item.PricingID, nil)) @@ -247,13 +270,11 @@ func TestPricingStore_ListPricingOrdersByStartDateAscending(t *testing.T) { 	api := newInMemoryPricingAPI() 	store := NewDynamoPricingStore(api, pricingTestTable()) -	endA := "2025-12-31"-	endB := "2026-06-30" 	rows := []PricingItem{ 		// Insert deliberately out of order.-		{PricingID: "p-b", StartDate: "2026-01-01", EndDate: &endB, PeakRate: 0.3, FeedInRate: 0.05, OffPeakSavingsRate: 0.1, CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z"},-		{PricingID: "p-a", StartDate: "2025-01-01", EndDate: &endA, PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08, CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z"},-		{PricingID: "p-c", StartDate: "2026-07-01", PeakRate: 0.32, FeedInRate: 0.05, OffPeakSavingsRate: 0.12, CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z"},+		{PricingID: "p-b", StartDate: "2026-01-01", EndDate: strPtr("2026-07-01"), DefaultRate: 0.3, FeedInRate: 0.05, CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z"},+		{PricingID: "p-a", StartDate: "2025-01-01", EndDate: strPtr("2026-01-01"), DefaultRate: 0.25, FeedInRate: 0.04, CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z"},+		{PricingID: "p-c", StartDate: "2026-07-01", DefaultRate: 0.32, FeedInRate: 0.05, CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z"}, 	} 	for _, r := range rows { 		require.NoError(t, store.PutPricing(context.Background(), r, nil))@@ -274,19 +295,15 @@ func TestPricingStore_ListPricingExcludesSentinelRow(t *testing.T) { 	// Seed both a real pricing row and the singleton sentinel directly. 	openID := "pricing-1" 	sentinel := PricingSentinel{-		PricingID:   pricingSentinelID,+		PricingID:   PricingSentinelID, 		OpenEndedID: &openID, 		UpdatedAt:   "2026-05-23T10:00:00Z", 	} 	av, err := attributevalue.MarshalMap(sentinel) 	require.NoError(t, err)-	api.items[pricingSentinelID] = av+	api.items[PricingSentinelID] = av -	item := PricingItem{-		PricingID: openID, StartDate: "2026-01-01",-		PeakRate: 0.3, FeedInRate: 0.05, OffPeakSavingsRate: 0.1,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}+	item := bandPricingItem(openID, "2026-01-01", nil) 	require.NoError(t, store.PutPricing(context.Background(), item, nil))  	got, err := store.ListPricing(context.Background())@@ -310,13 +327,13 @@ func TestPricingStore_GetSentinelRoundTrip(t *testing.T) {  	openID := "pricing-open" 	want := PricingSentinel{-		PricingID:   pricingSentinelID,+		PricingID:   PricingSentinelID, 		OpenEndedID: &openID, 		UpdatedAt:   "2026-05-23T10:00:00Z", 	} 	av, err := attributevalue.MarshalMap(want) 	require.NoError(t, err)-	api.items[pricingSentinelID] = av+	api.items[PricingSentinelID] = av  	got, err := store.GetSentinel(context.Background()) 	require.NoError(t, err)@@ -328,25 +345,20 @@ func TestPricingStore_UpdateClosedRateOnly(t *testing.T) { 	api := newInMemoryPricingAPI() 	store := NewDynamoPricingStore(api, pricingTestTable()) -	end := "2026-06-30"-	original := PricingItem{-		PricingID: "p-1", StartDate: "2026-01-01", EndDate: &end,-		PeakRate: 0.25, FeedInRate: 0.04, OffPeakSavingsRate: 0.08,-		CreatedAt: "2026-05-23T10:00:00Z", UpdatedAt: "2026-05-23T10:00:00Z",-	}+	original := bandPricingItem("p-1", "2026-01-01", strPtr("2026-07-01")) 	require.NoError(t, store.PutPricing(context.Background(), original, nil))  	// Edit rates only — both before and after are closed periods, so the 	// open-ended-id snapshot stays nil and no transaction is needed. 	updated := original-	updated.PeakRate = 0.3000+	updated.DefaultRate = 0.3000 	updated.FeedInRate = 0.0500 	updated.UpdatedAt = "2026-05-24T10:00:00Z" 	require.NoError(t, store.UpdatePricing(context.Background(), updated, nil))  	got := readPricing(t, api, original.PricingID) 	require.NotNil(t, got)-	assert.Equal(t, 0.3000, got.PeakRate)+	assert.Equal(t, 0.3000, got.DefaultRate) 	assert.Equal(t, "2026-05-24T10:00:00Z", got.UpdatedAt) 	assert.Equal(t, original.CreatedAt, got.CreatedAt, "createdAt must not change on update") }
internal/dynamo/pricing_transactional.go Modified +46 / -1
diff --git a/internal/dynamo/pricing_transactional.go b/internal/dynamo/pricing_transactional.goindex 8161cf2..62795fb 100644--- a/internal/dynamo/pricing_transactional.go+++ b/internal/dynamo/pricing_transactional.go@@ -24,6 +24,11 @@ var ErrPricingConcurrentWrite = errors.New("pricing: concurrent open-ended write // caller retries with a fresh UUID. var ErrPricingUUIDCollision = errors.New("pricing: uuid collision on new row") +// ErrPricingLegacyShape is returned when a write path encounters a row that+// is still the pre-migration three-rate shape. The API handler maps this to+// the legacy_shape validation code (AC 7.3).+var ErrPricingLegacyShape = errors.New("pricing: legacy three-rate row shape")+ // sentinelConditionExpression is the ConditionExpression placed on every // sentinel write. The first clause lets the very first transactional // write lazily create the sentinel; the second clause catches concurrent@@ -61,7 +66,7 @@ func (s *DynamoPricingStore) sentinelUpdate(newOpenEndedID, prevOpenEndedID *str 	return types.TransactWriteItem{ 		Update: &types.Update{ 			TableName:                 &s.table,-			Key:                       pricingKey(pricingSentinelID),+			Key:                       pricingKey(PricingSentinelID), 			UpdateExpression:          &expr, 			ConditionExpression:       &cond, 			ExpressionAttributeValues: values,@@ -179,7 +184,15 @@ func (s *DynamoPricingStore) deleteOpenEndedPeriod(ctx context.Context, id strin // newItem.PricingID when newItem is open-ended, or cleared when newItem // is closed. Three items per transaction: (1) sentinel, (2) closing-row // update, (3) new-row insert.+//+// closingEndDate is stored verbatim. Under exclusive end dates (Decision 5)+// the caller passes the successor's start date, so both rows carry the same+// literal switch date and the switch day belongs to the successor (AC 2.2) —+// no date arithmetic happens here or in the caller. func (s *DynamoPricingStore) ReplaceOpenEnded(ctx context.Context, closingID string, closingEndDate string, updatedAt string, newItem PricingItem) error {+	if err := s.rejectLegacyClosingRow(ctx, closingID); err != nil {+		return err+	} 	prevOpenEndedID := &closingID 	var newOpenEndedID *string 	if newItem.EndDate == nil {@@ -230,6 +243,38 @@ func (s *DynamoPricingStore) ReplaceOpenEnded(ctx context.Context, closingID str 	return nil } +// rejectLegacyClosingRow refuses a succession whose closing row is still the+// legacy three-rate shape (Q32).+//+// Every other write path issues a full-item Put, but the closing write here+// is a partial UpdateItem: patching a legacy row would leave it still+// legacy-detected while carrying an exclusive end date, which the read+// transform and then the migration would each shift by a day. Rewriting the+// row inside the transaction was rejected as the fix — this call carries no+// predecessor state, so a rewrite needs an extra read and can clobber a+// concurrent edit — and the cutover order already runs the migration first.+//+// A read failure is not treated as "not legacy": the succession is refused so+// a transient blip cannot produce a double-shifted end date.+func (s *DynamoPricingStore) rejectLegacyClosingRow(ctx context.Context, closingID string) error {+	out, err := s.client.GetItem(ctx, &dynamodb.GetItemInput{+		TableName: &s.table,+		Key:       pricingKey(closingID),+	})+	if err != nil {+		return fmt.Errorf("get closing pricing row (table=%s, pricingId=%s): %w", s.table, closingID, err)+	}+	if out.Item == nil {+		// The row vanished between the caller's validation scan and now.+		// Same outcome the transaction's ConditionExpression would produce.+		return ErrPricingConcurrentWrite+	}+	if IsLegacyPricingRow(out.Item) {+		return fmt.Errorf("%w (pricingId=%s): run cmd/migrate-pricing first", ErrPricingLegacyShape, closingID)+	}+	return nil+}+ // reasonHandler interprets one position in a TransactionCanceledException // Reasons slice. When the position's CancellationReason is a // ConditionalCheckFailed entry the handler returns the typed error;
internal/dynamo/pricing.go Modified +196 / -21
diff --git a/internal/dynamo/pricing.go b/internal/dynamo/pricing.goindex da80fa8..a4be8de 100644--- a/internal/dynamo/pricing.go+++ b/internal/dynamo/pricing.go@@ -4,30 +4,175 @@ import ( 	"context" 	"fmt" 	"sort"+	"time" +	"github.com/ArjenSchwarz/flux/internal/plan" 	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" 	"github.com/aws/aws-sdk-go-v2/service/dynamodb" 	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types" ) -// pricingSentinelID is the partition key of the singleton sentinel row+// PricingSentinelID is the partition key of the singleton sentinel row // that pins which pricing period (if any) is currently open-ended. The // row never appears in ListPricing output and is maintained inside every // TransactWriteItems request that introduces, retires, or replaces an // open-ended period (Decision 21).-const pricingSentinelID = "__open_ended"+//+// Exported because operator tools that scan the raw table (cmd/migrate-pricing)+// must filter the sentinel out by id before any shape detection runs — it is+// keyed, not shaped. A hand-copied literal there would be one edit away from+// the migration treating the sentinel as a pricing row.+const PricingSentinelID = "__open_ended"++// PricingWindow is one stored exception to a plan's default rate. Rate is+// absent on a free window — the domain ignores it there by contract.+type PricingWindow struct {+	Start string   `dynamodbav:"start" json:"start"`                   // HH:MM, Sydney local+	End   string   `dynamodbav:"end" json:"end"`                       // HH:MM, may be "24:00"+	Free  bool     `dynamodbav:"free" json:"free"`                     // true => no rate, valued via savingsReferenceRate+	Rate  *float64 `dynamodbav:"rate,omitempty" json:"rate,omitempty"` // AUD/kWh, 4dp+}  // PricingItem represents one row of the flux-pricing table. PricingID is // serialised as "id" so the Swift client decodes it through Identifiable.+//+// The row stores the plan as entered — a default rate plus exception windows+// (Decision 4) — not the derived full-day segmentation. EndDate is exclusive+// (Decision 5): the row prices [StartDate, EndDate), so succession writes the+// same literal date to both rows. type PricingItem struct {-	PricingID          string  `dynamodbav:"pricingId" json:"id"`-	StartDate          string  `dynamodbav:"startDate" json:"startDate"`                   // YYYY-MM-DD, Melbourne local calendar-	EndDate            *string `dynamodbav:"endDate,omitempty" json:"endDate,omitempty"`   // absent => open-ended-	PeakRate           float64 `dynamodbav:"peakRate" json:"peakRate"`                     // AUD/kWh, 4dp-	FeedInRate         float64 `dynamodbav:"feedInRate" json:"feedInRate"`                 // AUD/kWh, 4dp-	OffPeakSavingsRate float64 `dynamodbav:"offPeakSavingsRate" json:"offPeakSavingsRate"` // AUD/kWh, 4dp-	CreatedAt          string  `dynamodbav:"createdAt" json:"createdAt"`                   // RFC3339 UTC-	UpdatedAt          string  `dynamodbav:"updatedAt" json:"updatedAt"`                   // bumped on every write+	PricingID            string          `dynamodbav:"pricingId" json:"id"`+	StartDate            string          `dynamodbav:"startDate" json:"startDate"`                 // YYYY-MM-DD, Sydney local calendar+	EndDate              *string         `dynamodbav:"endDate,omitempty" json:"endDate,omitempty"` // exclusive switch date; absent => open-ended+	DefaultRate          float64         `dynamodbav:"defaultRate" json:"defaultRate"`             // AUD/kWh, 4dp+	Windows              []PricingWindow `dynamodbav:"windows" json:"windows"`+	FeedInRate           float64         `dynamodbav:"feedInRate" json:"feedInRate"`                                         // AUD/kWh, 4dp+	SavingsReferenceRate *float64        `dynamodbav:"savingsReferenceRate,omitempty" json:"savingsReferenceRate,omitempty"` // present iff a free window exists+	CreatedAt            string          `dynamodbav:"createdAt" json:"createdAt"`                                           // RFC3339 UTC+	UpdatedAt            string          `dynamodbav:"updatedAt" json:"updatedAt"`                                           // bumped on every write+}++// Plan converts the storage row into the domain plan the validation,+// segmentation, and window-resolution helpers operate on.+func (i PricingItem) Plan() plan.Plan {+	windows := make([]plan.Window, len(i.Windows))+	for n, w := range i.Windows {+		windows[n] = plan.Window{Start: w.Start, End: w.End, Free: w.Free}+		if w.Rate != nil {+			windows[n].Rate = *w.Rate+		}+	}+	end := ""+	if i.EndDate != nil {+		end = *i.EndDate+	}+	return plan.Plan{+		ID:             i.PricingID,+		StartDate:      i.StartDate,+		EndDate:        end,+		DefaultRate:    i.DefaultRate,+		Windows:        windows,+		FeedInRate:     i.FeedInRate,+		SavingsRefRate: i.SavingsReferenceRate,+	}+}++// PlansFromItems converts a list of storage rows to domain plans, ready for+// plan.PlanFor / plan.FreeWindow.+func PlansFromItems(items []PricingItem) []plan.Plan {+	plans := make([]plan.Plan, len(items))+	for i, item := range items {+		plans[i] = item.Plan()+	}+	return plans+}++// LegacyPricingItem is the pre-migration three-rate row. It exists only so+// the transform below has something to decode into; nothing writes this shape+// any more.+type LegacyPricingItem struct {+	PricingID          string  `dynamodbav:"pricingId"`+	StartDate          string  `dynamodbav:"startDate"`+	EndDate            *string `dynamodbav:"endDate,omitempty"` // INCLUSIVE, unlike the band shape+	PeakRate           float64 `dynamodbav:"peakRate"`+	FeedInRate         float64 `dynamodbav:"feedInRate"`+	OffPeakSavingsRate float64 `dynamodbav:"offPeakSavingsRate"`+	CreatedAt          string  `dynamodbav:"createdAt"`+	UpdatedAt          string  `dynamodbav:"updatedAt"`+}++// legacyPricingMarker is the attribute whose presence identifies a+// pre-migration row. The band shape has no such attribute.+const legacyPricingMarker = "peakRate"++// legacyFreeWindow is the window every legacy period's historical off-peak+// data was computed under (AC 5.1). It is the free band the transform gives+// each migrated plan.+var legacyFreeWindow = PricingWindow{Start: "11:00", End: "14:00", Free: true}++// IsLegacyPricingRow reports whether a raw attribute map is the legacy+// three-rate shape.+//+// Detection has to happen on the raw map: attributevalue silently drops+// attributes with no matching struct field, so unmarshalling a legacy row+// into PricingItem yields a zero-rate plan with no windows rather than+// anything recognisably wrong.+func IsLegacyPricingRow(av map[string]types.AttributeValue) bool {+	_, ok := av[legacyPricingMarker]+	return ok+}++// TransformLegacyPricing converts a legacy three-rate period into the band+// model (AC 5.1). Defined once and shared by the transitional read path and+// cmd/migrate-pricing so the two can never disagree about what a migrated row+// looks like.+//+// The end date shifts from inclusive to exclusive by one day, so the period+// prices exactly the same calendar days before and after migration (AC 5.2).+func TransformLegacyPricing(old LegacyPricingItem) (PricingItem, error) {+	savings := old.OffPeakSavingsRate+	item := PricingItem{+		PricingID:            old.PricingID,+		StartDate:            old.StartDate,+		DefaultRate:          old.PeakRate,+		Windows:              []PricingWindow{legacyFreeWindow},+		FeedInRate:           old.FeedInRate,+		SavingsReferenceRate: &savings,+		CreatedAt:            old.CreatedAt,+		UpdatedAt:            old.UpdatedAt,+	}+	if old.EndDate != nil {+		parsed, err := time.Parse(pricingDateLayout, *old.EndDate)+		if err != nil {+			return PricingItem{}, fmt.Errorf("legacy pricing endDate (pricingId=%s, endDate=%q): %w", old.PricingID, *old.EndDate, err)+		}+		exclusive := parsed.AddDate(0, 0, 1).Format(pricingDateLayout)+		item.EndDate = &exclusive+	}+	return item, nil+}++// pricingDateLayout is the plan date format. Dates are calendar-only, so+// parsing in UTC is sufficient — no wall-clock arithmetic happens here.+const pricingDateLayout = "2006-01-02"++// decodePricingRow unmarshals one raw row into the band shape, converting a+// legacy row on the way (Q28). This keeps a band-aware poller or Lambda+// deployed ahead of the migration working correctly; the conversion is+// removed once the migration has run.+func decodePricingRow(av map[string]types.AttributeValue, desc string) (PricingItem, error) {+	if IsLegacyPricingRow(av) {+		var legacy LegacyPricingItem+		if err := attributevalue.UnmarshalMap(av, &legacy); err != nil {+			return PricingItem{}, fmt.Errorf("unmarshal legacy %s: %w", desc, err)+		}+		return TransformLegacyPricing(legacy)+	}+	var item PricingItem+	if err := attributevalue.UnmarshalMap(av, &item); err != nil {+		return PricingItem{}, fmt.Errorf("unmarshal %s: %w", desc, err)+	}+	return item, nil }  // PricingSentinel is the singleton row (pricingId = "__open_ended") whose@@ -103,23 +248,38 @@ func pricingKey(id string) map[string]types.AttributeValue { const pricingListPageLimit = 200  func (s *DynamoPricingStore) ListPricing(ctx context.Context) ([]PricingItem, error) {+	return ListPricingRows(ctx, s.client, s.table)+}++// PricingScanAPI is the single DynamoDB call the pricing read needs. The+// operator CLIs read plans without ever writing them, so they satisfy this+// rather than the full PricingAPI — the Lambda keeps sole write access.+type PricingScanAPI interface {+	Scan(ctx context.Context, params *dynamodb.ScanInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error)+}++// ListPricingRows is the shared implementation behind ListPricing: it pages+// the table, skips the sentinel, and converts legacy rows on the way. Exported+// so the backfill and migration CLIs get identical decoding without building a+// read/write store.+func ListPricingRows(ctx context.Context, client PricingScanAPI, table string) ([]PricingItem, error) { 	items := make([]PricingItem, 0) 	limit := int32(pricingListPageLimit)-	input := &dynamodb.ScanInput{TableName: &s.table, Limit: &limit}+	input := &dynamodb.ScanInput{TableName: &table, Limit: &limit} 	for {-		out, err := s.client.Scan(ctx, input)+		out, err := client.Scan(ctx, input) 		if err != nil {-			return nil, fmt.Errorf("scan pricing (table=%s): %w", s.table, err)+			return nil, fmt.Errorf("scan pricing (table=%s): %w", table, err) 		} 		for _, av := range out.Items { 			// Skip the sentinel — identified by partition key, not by 			// shape — before attempting to decode into PricingItem.-			if idAV, ok := av["pricingId"].(*types.AttributeValueMemberS); ok && idAV.Value == pricingSentinelID {+			if idAV, ok := av["pricingId"].(*types.AttributeValueMemberS); ok && idAV.Value == PricingSentinelID { 				continue 			}-			var item PricingItem-			if err := attributevalue.UnmarshalMap(av, &item); err != nil {-				return nil, fmt.Errorf("unmarshal pricing (table=%s): %w", s.table, err)+			item, err := decodePricingRow(av, fmt.Sprintf("pricing (table=%s)", table))+			if err != nil {+				return nil, err 			} 			items = append(items, item) 		}@@ -135,17 +295,32 @@ func (s *DynamoPricingStore) ListPricing(ctx context.Context) ([]PricingItem, er }  // GetPricing returns the pricing row with the given id, or nil if absent.+// It does not use the shared getItem helper because a legacy row needs the+// raw attribute map to be recognised before it is decoded. func (s *DynamoPricingStore) GetPricing(ctx context.Context, id string) (*PricingItem, error) {-	return getItem[PricingItem](ctx, s.client, s.table, pricingKey(id),-		fmt.Sprintf("pricing (table=%s, pricingId=%s)", s.table, id),-	)+	desc := fmt.Sprintf("pricing (table=%s, pricingId=%s)", s.table, id)+	out, err := s.client.GetItem(ctx, &dynamodb.GetItemInput{+		TableName: &s.table,+		Key:       pricingKey(id),+	})+	if err != nil {+		return nil, fmt.Errorf("get %s: %w", desc, err)+	}+	if out.Item == nil {+		return nil, nil+	}+	item, err := decodePricingRow(out.Item, desc)+	if err != nil {+		return nil, err+	}+	return &item, nil }  // GetSentinel returns the sentinel row, or nil when it has not yet been // provisioned. The first transactional write lazily creates it via a // ConditionExpression that tolerates the absent state. func (s *DynamoPricingStore) GetSentinel(ctx context.Context) (*PricingSentinel, error) {-	return getItem[PricingSentinel](ctx, s.client, s.table, pricingKey(pricingSentinelID),+	return getItem[PricingSentinel](ctx, s.client, s.table, pricingKey(PricingSentinelID), 		fmt.Sprintf("pricing sentinel (table=%s)", s.table), 	) }
internal/integration/derivedstats_e2e_test.go Modified +45 / -6
diff --git a/internal/integration/derivedstats_e2e_test.go b/internal/integration/derivedstats_e2e_test.goindex a570303..2cb0d91 100644--- a/internal/integration/derivedstats_e2e_test.go+++ b/internal/integration/derivedstats_e2e_test.go@@ -134,12 +134,13 @@ func TestEndToEnd_DerivedStatsRoundTrip(t *testing.T) { 	// the writer side of the AC 6.7 contract end-to-end (no hand-rolled 	// payload). 	cfg := &config.Config{-		Serial:       serial,-		Location:     loc,-		OffpeakStart: 11 * time.Hour,-		OffpeakEnd:   14 * time.Hour,+		Serial:   serial,+		Location: loc, 	}-	p := poller.New(nil, store, cfg)+	// The free window comes from the plan pricing the day (Decision 2), so+	// the pass needs a plan source. fixedPlanStore prices every date at+	// 11:00–14:00, the window these fixture expectations were written against.+	p := poller.New(nil, store, fixedPlanStore{}, cfg) 	p.SetMetrics(poller.NoopMetrics{}) 	// 02:00 AEST on 2026-04-15 ⇒ yesterday-in-Sydney = 2026-04-14. 	p.SetNow(func() time.Time { return time.Date(2026, 4, 15, 2, 0, 0, 0, loc) })@@ -185,7 +186,8 @@ func TestEndToEnd_DerivedStatsRoundTrip(t *testing.T) { 	// Verify the Lambda /day handler reads the row and surfaces the 	// derivedStats sections to clients (read side of the AC 6.7 contract). 	const apiToken = "test-token"-	h := api.NewHandler(reader, nil, serial, apiToken, "11:00", "14:00")+	h := api.NewHandler(reader, nil, serial, apiToken)+	h.SetPricingStore(fixedPlanStore{}) 	// 12:00 AEST on 2026-04-15 ⇒ /day for 2026-04-14 takes the past-date 	// branch (reads from storage, not the readings table). 	h.SetNow(func() time.Time { return time.Date(2026, 4, 15, 12, 0, 0, 0, loc) })@@ -264,3 +266,40 @@ func TestEndToEnd_DerivedStatsRoundTrip(t *testing.T) { 		require.InDelta(t, *storedByKind[kind].SolarKwh, *b.SolarKwh, 1e-9, "/history SolarKwh must match storage for %s", kind) 	} }++// fixedPlanStore is a read-only api.PricingStore serving one open-ended plan+// with the free window this test's fixtures were computed under. The Lambda+// derives the off-peak window from plans, so /day and /history need a plan;+// the pricing table itself is not what this test exercises, so the store is+// stubbed rather than provisioned in DynamoDB Local.+type fixedPlanStore struct{}++func (fixedPlanStore) ListPricing(context.Context) ([]dynamo.PricingItem, error) {+	savings := 0.15+	return []dynamo.PricingItem{{+		PricingID:            "e2e-plan",+		StartDate:            "2000-01-01",+		DefaultRate:          0.3,+		Windows:              []dynamo.PricingWindow{{Start: "11:00", End: "14:00", Free: true}},+		FeedInRate:           0.05,+		SavingsReferenceRate: &savings,+	}}, nil+}++func (fixedPlanStore) GetPricing(context.Context, string) (*dynamo.PricingItem, error) {+	return nil, nil+}++func (fixedPlanStore) GetSentinel(context.Context) (*dynamo.PricingSentinel, error) {+	return nil, nil+}++func (fixedPlanStore) PutPricing(context.Context, dynamo.PricingItem, *string) error { return nil }++func (fixedPlanStore) UpdatePricing(context.Context, dynamo.PricingItem, *string) error { return nil }++func (fixedPlanStore) DeletePricing(context.Context, string, *string) error { return nil }++func (fixedPlanStore) ReplaceOpenEnded(context.Context, string, string, string, dynamo.PricingItem) error {+	return nil+}
internal/plan/costs.go Added +222 / -0
diff --git a/internal/plan/costs.go b/internal/plan/costs.gonew file mode 100644index 0000000..b8976d7--- /dev/null+++ b/internal/plan/costs.go@@ -0,0 +1,222 @@+package plan++// This file holds the three-tier day-cost resolution (Decision 6). It is the+// Go side of a formula that also lives in FluxCore; the two are pinned to each+// other by the shared vectors in internal/api/testdata/pricing_costs.json.+//+// Tier 2 is the pre-band DayCosts formula verbatim — server-peak preference,+// zero clamp, nil-off-peak path. That exactness is what makes AC 5.2 hold: the+// migration tool computes its goldens with this same code, so a migrated+// single-rate plan reprices every historical day to the identical number.++// legacyFreeWindowStart / legacyFreeWindowEnd is the window every pre-feature+// off-peak row was integrated under. Rows predating the geometry snapshot+// carry no windowStart/windowEnd, and this is the only window they can have+// had.+const (+	legacyFreeWindowStart = "11:00"+	legacyFreeWindowEnd   = "14:00"+)++// BandImport is one stored per-band import figure. Each entry snapshots the+// geometry it was captured under (Q23) so a later window edit is detectable as+// a mismatch rather than silently mispricing the day.+type BandImport struct {+	Start string+	End   string+	Kwh   float64+}++// OffpeakRow is the flux-offpeak row's contribution to costing. That row+// exclusively owns free-window import (Q31), so it is the only source of the+// free band's kWh.+type OffpeakRow struct {+	GridImportKwh float64+	// WindowStart / WindowEnd is the geometry the row was integrated under.+	// Empty means a pre-feature row, which can only be 11:00–14:00.+	WindowStart string+	WindowEnd   string+	// IntegratedAt / SampleCount are the integration provenance. A row with+	// IntegratedAt set but no samples is a zero-delta artifact, not a measured+	// zero, so it cannot be used to price a free band.+	IntegratedAt string+	SampleCount  int+}++// Usable reports whether the row's free-window import is a real measurement.+func (r OffpeakRow) Usable() bool {+	return r.IntegratedAt == "" || r.SampleCount > 0+}++// Geometry returns the window the row was integrated under, substituting the+// pre-feature default when the row carries no snapshot.+func (r OffpeakRow) Geometry() (start, end string) {+	if r.WindowStart == "" || r.WindowEnd == "" {+		return legacyFreeWindowStart, legacyFreeWindowEnd+	}+	return r.WindowStart, r.WindowEnd+}++// DayEnergy is one day's stored energy, as cost resolution sees it. Pointer+// fields distinguish "never recorded" from a measured zero — the distinction+// tier 2's formula table turns on.+type DayEnergy struct {+	EInput            *float64+	EOutput           *float64+	Offpeak           *OffpeakRow+	PeakGridImportKwh *float64+	BandImports       []BandImport+}++// Costs is the four figures every screen shows for a day or a period.+type Costs struct {+	ImportCost   float64+	FeedInIncome float64+	Net          float64+	Savings      float64+}++// Tier identifies which resolution path produced a Costs value. Exposed so+// tests and the migration tool can assert on the path, not just the number.+type Tier int++const (+	// TierBanded prices each rated band at its own rate from the stored split.+	TierBanded Tier = 1+	// TierSingleRate is the pre-band formula, applicable whenever the plan's+	// rated segments share one rate — which every migrated legacy plan does.+	TierSingleRate Tier = 2+	// TierFallback prices all import at the plan's highest rate with no+	// savings (AC 3.6). Reachable only for multi-rate plans.+	TierFallback Tier = 3+)++// DayCosts resolves one day's costs under the plan pricing that day.+// Resolution order is tier 1 → 2 → 3; the tier that produced the result is+// returned alongside it.+func DayCosts(p Plan, e DayEnergy) (Costs, Tier) {+	feedIn := deref(e.EOutput) * p.FeedInRate+	finish := func(importCost, savings float64, tier Tier) (Costs, Tier) {+		return Costs{+			ImportCost:   importCost,+			FeedInIncome: feedIn,+			Net:          importCost - feedIn,+			Savings:      savings,+		}, tier+	}++	rated := RatedSegments(p)++	if importCost, savings, ok := bandedCosts(p, rated, e); ok {+		return finish(importCost, savings, TierBanded)+	}+	if rate, ok := singleRate(rated); ok {+		importCost, savings := singleRateCosts(p, e, rate)+		return finish(importCost, savings, TierSingleRate)+	}+	// AC 3.6: an unresolvable split prices everything at the highest rate and+	// shows no savings — the conservative overestimate every screen must agree+	// on.+	return finish(deref(e.EInput)*maxRate(rated), 0, TierFallback)+}++// bandedCosts prices the day from the stored split. It applies only when the+// split's geometry exactly matches the plan's rated segments AND the free+// band's import is resolvable — a partially known split is unavailable+// (AC 3.6), not partially used.+func bandedCosts(p Plan, rated []Segment, e DayEnergy) (importCost, savings float64, ok bool) {+	if len(e.BandImports) != len(rated) || len(rated) == 0 {+		return 0, 0, false+	}+	for i, seg := range rated {+		if e.BandImports[i].Start != seg.Start || e.BandImports[i].End != seg.End {+			return 0, 0, false+		}+		importCost += e.BandImports[i].Kwh * seg.Rate+	}++	freeStart, freeEnd, hasFree := p.freeWindowStrings()+	if !hasFree {+		// No free band: the rated segments are the whole day and there is+		// nothing to value as savings.+		return importCost, 0, true+	}+	if e.Offpeak == nil || !e.Offpeak.Usable() {+		return 0, 0, false+	}+	if rowStart, rowEnd := e.Offpeak.Geometry(); rowStart != freeStart || rowEnd != freeEnd {+		return 0, 0, false+	}+	if p.SavingsRefRate == nil {+		return importCost, 0, true+	}+	return importCost, e.Offpeak.GridImportKwh * *p.SavingsRefRate, true+}++// singleRateCosts is the pre-band formula, unchanged. Peak kWh prefers the+// server-computed value over the eInput − off-peak residual: the two differ by+// ~1.5% by design (a shared sampling artifact), and pricing the measured value+// is what keeps migrated history identical (Q30).+func singleRateCosts(p Plan, e DayEnergy, rate float64) (importCost, savings float64) {+	total := deref(e.EInput)+	if e.Offpeak == nil {+		if e.PeakGridImportKwh != nil {+			return *e.PeakGridImportKwh * rate, 0+		}+		return total * rate, 0+	}++	off := e.Offpeak.GridImportKwh+	peak := max(0, total-off)+	if e.PeakGridImportKwh != nil {+		peak = *e.PeakGridImportKwh+	}+	if p.SavingsRefRate != nil {+		savings = off * *p.SavingsRefRate+	}+	return peak * rate, savings+}++// freeWindowStrings returns the plan's free band boundaries as they appear in+// the segmentation, for geometry comparison against a stored row.+func (p Plan) freeWindowStrings() (start, end string, ok bool) {+	for _, seg := range Segments(p) {+		if seg.Free {+			return seg.Start, seg.End, true+		}+	}+	return "", "", false+}++// singleRate returns the rate shared by every rated segment. ok is false when+// the segments carry more than one rate — the only case that can reach the+// fallback tier.+func singleRate(rated []Segment) (float64, bool) {+	if len(rated) == 0 {+		return 0, false+	}+	rate := rated[0].Rate+	for _, seg := range rated[1:] {+		if seg.Rate != rate {+			return 0, false+		}+	}+	return rate, true+}++// maxRate returns the highest rate among the rated segments — the rate the+// fallback tier prices the whole day at.+func maxRate(rated []Segment) float64 {+	var highest float64+	for _, seg := range rated {+		highest = max(highest, seg.Rate)+	}+	return highest+}++func deref(v *float64) float64 {+	if v == nil {+		return 0+	}+	return *v+}
internal/plan/plan_test.go Added +340 / -0
diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.gonew file mode 100644index 0000000..697a266--- /dev/null+++ b/internal/plan/plan_test.go@@ -0,0 +1,340 @@+package plan++import (+	"testing"++	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// currentPlan mirrors the plan in production today: free 11:00–14:00 with a+// single flat rate covering the rest of the day (requirement 1.8).+func currentPlan() Plan {+	savings := 0.35+	return Plan{+		ID:             "current",+		StartDate:      "2026-01-01",+		DefaultRate:    0.35,+		Windows:        []Window{{Start: "11:00", End: "14:00", Free: true}},+		FeedInRate:     0.05,+		SavingsRefRate: &savings,+	}+}++// newPlan mirrors the incoming plan: free 10:00–15:00, a cheaper 01:00–06:00+// band, and the standard rate everywhere else (requirement 1.8 / Q3).+func newPlan() Plan {+	savings := 0.35+	return Plan{+		ID:          "successor",+		StartDate:   "2026-08-01",+		DefaultRate: 0.35,+		Windows: []Window{+			{Start: "10:00", End: "15:00", Free: true},+			{Start: "01:00", End: "06:00", Rate: 0.28},+		},+		FeedInRate:     0.05,+		SavingsRefRate: &savings,+	}+}++// codes flattens a validation result to its codes so tests assert on the rule+// that fired rather than on message wording.+func codes(errs []ValidationError) []string {+	out := make([]string, len(errs))+	for i, e := range errs {+		out[i] = e.Code+	}+	return out+}++// TestParseBandTime pins the band-boundary parser. It is deliberately NOT+// derivedstats.ParseOffpeakWindow: that parser rejects h > 23 and would reject+// the 24:00 end-of-day boundary every plan's last segment carries (Q34).+func TestParseBandTime(t *testing.T) {+	t.Parallel()+	tests := map[string]struct {+		in     string+		want   int+		wantOK bool+	}{+		"midnight":            {in: "00:00", want: 0, wantOK: true},+		"morning boundary":    {in: "01:00", want: 60, wantOK: true},+		"free window start":   {in: "11:00", want: 660, wantOK: true},+		"last minute of day":  {in: "23:59", want: 1439, wantOK: true},+		"end of day":          {in: "24:00", want: 1440, wantOK: true},+		"past end of day":     {in: "24:01", wantOK: false},+		"hour out of range":   {in: "25:00", wantOK: false},+		"minute out of range": {in: "10:60", wantOK: false},+		"unpadded hour":       {in: "9:00", wantOK: false},+		"missing colon":       {in: "1000", wantOK: false},+		"non-numeric":         {in: "aa:bb", wantOK: false},+		"empty":               {in: "", wantOK: false},+		"too long":            {in: "10:00:00", wantOK: false},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			got, ok := ParseBandTime(tc.in)+			assert.Equal(t, tc.wantOK, ok)+			if tc.wantOK {+				assert.Equal(t, tc.want, got)+			}+		})+	}+}++// TestFormatBandTime pins the inverse of ParseBandTime, including the 24:00+// end-of-day representation.+func TestFormatBandTime(t *testing.T) {+	t.Parallel()+	tests := map[string]struct {+		in   int+		want string+	}{+		"midnight":   {in: 0, want: "00:00"},+		"one am":     {in: 60, want: "01:00"},+		"half past":  {in: 690, want: "11:30"},+		"end of day": {in: 1440, want: "24:00"},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			assert.Equal(t, tc.want, FormatBandTime(tc.in))+		})+	}+}++// TestValidateAcceptsRealPlans covers requirement 1.8: the model must express+// both the current and the incoming plan without a validation failure.+func TestValidateAcceptsRealPlans(t *testing.T) {+	t.Parallel()+	tests := map[string]Plan{+		"current plan": currentPlan(),+		"new plan":     newPlan(),+		"no free band": {StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05},+		"open ended":   currentPlan(),+		"rated windows only": {+			StartDate:   "2026-01-01",+			DefaultRate: 0.35,+			Windows:     []Window{{Start: "01:00", End: "06:00", Rate: 0.28}},+			FeedInRate:  0.05,+		},+	}+	for name, p := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			assert.Empty(t, p.Validate())+		})+	}+}++// TestValidateZeroWidthDefaultRemainder covers the noRatedBand carve-out: a+// free window abutting rated windows that tile the rest of the day leaves a+// zero-width default remainder, which is valid — the plan still has rated+// bands (requirement 1.3).+func TestValidateZeroWidthDefaultRemainder(t *testing.T) {+	t.Parallel()+	savings := 0.35+	p := Plan{+		StartDate:   "2026-01-01",+		DefaultRate: 0.35,+		Windows: []Window{+			{Start: "00:00", End: "10:00", Rate: 0.28},+			{Start: "10:00", End: "15:00", Free: true},+			{Start: "15:00", End: "24:00", Rate: 0.30},+		},+		FeedInRate:     0.05,+		SavingsRefRate: &savings,+	}+	assert.Empty(t, p.Validate())+}++// TestValidateBandRules covers each band-specific validation code from the+// design's error table (AC 1.7 / 7.2).+func TestValidateBandRules(t *testing.T) {+	t.Parallel()+	savings := 0.35+	tests := map[string]struct {+		plan     Plan+		wantCode string+	}{+		"unparseable window start": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{{Start: "9:00", End: "14:00", Rate: 0.2}}},+			wantCode: CodeBandWindowInvalid,+		},+		"unparseable window end": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{{Start: "11:00", End: "24:30", Rate: 0.2}}},+			wantCode: CodeBandWindowInvalid,+		},+		"inverted window": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{{Start: "14:00", End: "11:00", Rate: 0.2}}},+			wantCode: CodeBandWindowInvalid,+		},+		"zero width window": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{{Start: "11:00", End: "11:00", Rate: 0.2}}},+			wantCode: CodeBandWindowInvalid,+		},+		"overlapping windows": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{+					{Start: "10:00", End: "14:00", Rate: 0.2},+					{Start: "13:00", End: "16:00", Rate: 0.3},+				}},+			wantCode: CodeBandOverlap,+		},+		"overlapping windows out of order": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{+					{Start: "13:00", End: "16:00", Rate: 0.3},+					{Start: "10:00", End: "14:00", Rate: 0.2},+				}},+			wantCode: CodeBandOverlap,+		},+		"two free bands": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				SavingsRefRate: &savings,+				Windows: []Window{+					{Start: "10:00", End: "12:00", Free: true},+					{Start: "13:00", End: "15:00", Free: true},+				}},+			wantCode: CodeMultipleFreeBands,+		},+		"free band without savings rate": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{{Start: "11:00", End: "14:00", Free: true}}},+			wantCode: CodeSavingsRateMissing,+		},+		"free band spans whole day": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				SavingsRefRate: &savings,+				Windows:        []Window{{Start: "00:00", End: "24:00", Free: true}}},+			wantCode: CodeNoRatedBand,+		},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			assert.Contains(t, codes(tc.plan.Validate()), tc.wantCode)+		})+	}+}++// TestValidateRateBounds covers requirement 1.6 — bounds and precision apply+// to every rate the plan carries, including per-window rates.+func TestValidateRateBounds(t *testing.T) {+	t.Parallel()+	overCap := 10.0001+	tests := map[string]struct {+		plan     Plan+		wantCode string+	}{+		"default rate negative": {+			plan:     Plan{StartDate: "2026-01-01", DefaultRate: -0.01, FeedInRate: 0.05},+			wantCode: CodeRateOutOfRange,+		},+		"default rate over cap": {+			plan:     Plan{StartDate: "2026-01-01", DefaultRate: 10.0001, FeedInRate: 0.05},+			wantCode: CodeRateOutOfRange,+		},+		"feed-in rate over cap": {+			plan:     Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 10.5},+			wantCode: CodeRateOutOfRange,+		},+		"window rate over cap": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{{Start: "01:00", End: "06:00", Rate: 11}}},+			wantCode: CodeRateOutOfRange,+		},+		"savings rate over cap": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				SavingsRefRate: &overCap,+				Windows:        []Window{{Start: "11:00", End: "14:00", Free: true}}},+			wantCode: CodeRateOutOfRange,+		},+		"default rate too precise": {+			plan:     Plan{StartDate: "2026-01-01", DefaultRate: 0.12345, FeedInRate: 0.05},+			wantCode: CodeRatePrecision,+		},+		"feed-in rate too precise": {+			plan:     Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.054321},+			wantCode: CodeRatePrecision,+		},+		"window rate too precise": {+			plan: Plan{StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+				Windows: []Window{{Start: "01:00", End: "06:00", Rate: 0.123456}}},+			wantCode: CodeRatePrecision,+		},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			assert.Contains(t, codes(tc.plan.Validate()), tc.wantCode)+		})+	}+}++// TestValidateFreeWindowRateIgnored pins that a free window's Rate field is+// not rate-checked — it carries no rate by contract (design: "Rate ignored+// when Free"), so a stray value must not fail validation.+func TestValidateFreeWindowRateIgnored(t *testing.T) {+	t.Parallel()+	savings := 0.35+	p := Plan{+		StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+		SavingsRefRate: &savings,+		Windows:        []Window{{Start: "11:00", End: "14:00", Free: true, Rate: 99.999999}},+	}+	assert.Empty(t, p.Validate())+}++// TestValidateDates covers the exclusive-end date rules (Decision 5): a+// zero-day plan (endDate == startDate) is rejected alongside inverted ranges.+func TestValidateDates(t *testing.T) {+	t.Parallel()+	tests := map[string]struct {+		startDate string+		endDate   string+		wantCode  string // empty => valid+	}{+		"open ended":            {startDate: "2026-01-01"},+		"closed range":          {startDate: "2026-01-01", endDate: "2026-08-01"},+		"one day plan":          {startDate: "2026-01-01", endDate: "2026-01-02"},+		"zero day plan":         {startDate: "2026-01-01", endDate: "2026-01-01", wantCode: CodeInvertedDates},+		"inverted range":        {startDate: "2026-08-01", endDate: "2026-01-01", wantCode: CodeInvertedDates},+		"malformed start date":  {startDate: "2026-1-1", wantCode: CodeInvertedDates},+		"empty start date":      {startDate: "", wantCode: CodeInvertedDates},+		"malformed end date":    {startDate: "2026-01-01", endDate: "01-08-2026", wantCode: CodeInvertedDates},+		"impossible start date": {startDate: "2026-02-30", wantCode: CodeInvertedDates},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			p := Plan{StartDate: tc.startDate, EndDate: tc.endDate, DefaultRate: 0.35, FeedInRate: 0.05}+			got := codes(p.Validate())+			if tc.wantCode == "" {+				assert.Empty(t, got)+				return+			}+			assert.Contains(t, got, tc.wantCode)+		})+	}+}++// TestValidationErrorCarriesMessage pins that every emitted error has both a+// machine-readable code and a human-readable message — the API surfaces the+// code, the editor surfaces the message.+func TestValidationErrorCarriesMessage(t *testing.T) {+	t.Parallel()+	p := Plan{StartDate: "2026-01-01", DefaultRate: -1, FeedInRate: 0.05}+	errs := p.Validate()+	require.NotEmpty(t, errs)+	for _, e := range errs {+		assert.NotEmpty(t, e.Code)+		assert.NotEmpty(t, e.Message)+	}+}
internal/plan/plan.go Added +279 / -0
diff --git a/internal/plan/plan.go b/internal/plan/plan.gonew file mode 100644index 0000000..efb86a7--- /dev/null+++ b/internal/plan/plan.go@@ -0,0 +1,279 @@+// Package plan holds the electricity-plan domain: the band model, its+// validation rules, the derived full-day segmentation, and per-date plan+// selection. It is a leaf package — it imports nothing from other Flux+// packages — so the Lambda API, the poller, the backfill CLIs, and the+// migration tool can all depend on it without forming a cycle (mirroring+// the derivedstats layering).+//+// A plan is stored as entered: a default rate plus the exception windows+// that deviate from it (Decision 4). The contiguous full-day band list the+// requirements describe is derived on demand by Segments, which makes gaps+// and partial coverage unrepresentable — uncovered time simply carries the+// default rate.+package plan++import (+	"fmt"+	"math"+	"sort"+	"time"+)++// Validation codes. These are the wire values the API returns in the+// pricing error envelope's "error" field and the Swift client mirrors as+// PricingValidationReason cases, so they follow the snake_case convention+// the existing pricing codes established.+const (+	CodeInvertedDates      = "inverted_dates"+	CodeBandWindowInvalid  = "band_window_invalid"+	CodeBandOverlap        = "band_overlap"+	CodeMultipleFreeBands  = "multiple_free_bands"+	CodeSavingsRateMissing = "savings_rate_missing"+	CodeNoRatedBand        = "no_rated_band"+	CodeRatePrecision      = "rate_precision"+	CodeRateOutOfRange     = "rate_out_of_range"+)++// RateCap is the per-rate upper bound carried over from the flat-rate model+// (daily-costs Decision 12) — 10× the highest plausible AU retail tariff,+// which catches order-of-magnitude typos without constraining real use.+const RateCap = 10.0++// minutesPerDay is the exclusive upper bound of a band boundary. Boundaries+// are minute-of-day values in [0, 1440]; 1440 ("24:00") is end-of-day and is+// only ever a window/segment End.+const minutesPerDay = 24 * 60++// dateLayout is the calendar-date format used for plan start/end dates,+// interpreted in Australia/Sydney local time (Q19).+const dateLayout = "2006-01-02"++// Window is one exception to a plan's default rate: a half-open [Start, End)+// slice of the day that is either free or carries its own rate. Boundaries+// are "HH:MM" in Sydney local time; End may be "24:00". Rate is ignored when+// Free is set.+type Window struct {+	Start string+	End   string+	Free  bool+	Rate  float64+}++// Plan is one pricing plan: a date range, a default import rate, the+// exception windows, a flat feed-in rate, and — when the plan has a free+// window — the rate free-window energy is valued at.+//+// EndDate is exclusive (Decision 5): the plan prices days in+// [StartDate, EndDate), so a plan ending on the same date its successor+// starts hands the switch day to the successor with no ±1 arithmetic. An+// empty EndDate means open-ended.+//+// SavingsRefRate is a pointer so "no savings reference rate was supplied" is+// distinguishable from a supplied $0.00 — the difference the+// savings_rate_missing rule turns on.+type Plan struct {+	ID             string+	StartDate      string+	EndDate        string+	DefaultRate    float64+	Windows        []Window+	FeedInRate     float64+	SavingsRefRate *float64+}++// ValidationError is one violated rule. Code is the machine-readable value+// the API and the Swift client switch on; Message is the human-readable+// description the editor surfaces.+type ValidationError struct {+	Code    string+	Message string+}++// ParseBandTime converts an "HH:MM" band boundary to minutes since midnight.+// Unlike derivedstats.ParseOffpeakWindow it accepts "24:00" (1440) as the+// end-of-day boundary every plan's last segment carries; reusing that parser+// here would reject every plan (Q34).+func ParseBandTime(s string) (int, bool) {+	if len(s) != 5 || s[2] != ':' {+		return 0, false+	}+	for _, i := range [4]int{0, 1, 3, 4} {+		if s[i] < '0' || s[i] > '9' {+			return 0, false+		}+	}+	h := int(s[0]-'0')*10 + int(s[1]-'0')+	m := int(s[3]-'0')*10 + int(s[4]-'0')+	if m > 59 {+		return 0, false+	}+	total := h*60 + m+	if total > minutesPerDay {+		return 0, false+	}+	return total, true+}++// FormatBandTime is the inverse of ParseBandTime, rendering 1440 as "24:00".+func FormatBandTime(minutes int) string {+	return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60)+}++// Validate returns every violated rule, in a deterministic order. An empty+// result means the plan is acceptable. Cross-plan rules (date-range overlap+// between plans, the single-open-ended rule) are the caller's job — they need+// the whole plan set, which this method does not see.+func (p Plan) Validate() []ValidationError {+	var errs []ValidationError+	add := func(code, format string, args ...any) {+		errs = append(errs, ValidationError{Code: code, Message: fmt.Sprintf(format, args...)})+	}++	errs = append(errs, p.validateDates()...)++	// Band geometry. Windows that fail to parse are excluded from the+	// overlap and coverage checks below — reporting "overlap" on top of an+	// unparseable boundary would be noise, not a second problem.+	parsed := make([]parsedWindow, 0, len(p.Windows))+	for i, w := range p.Windows {+		start, okStart := ParseBandTime(w.Start)+		end, okEnd := ParseBandTime(w.End)+		switch {+		case !okStart:+			add(CodeBandWindowInvalid, "window %d: start %q must be HH:MM between 00:00 and 24:00", i+1, w.Start)+		case !okEnd:+			add(CodeBandWindowInvalid, "window %d: end %q must be HH:MM between 00:00 and 24:00", i+1, w.End)+		case start >= end:+			add(CodeBandWindowInvalid, "window %d: start %s must precede end %s", i+1, w.Start, w.End)+		default:+			parsed = append(parsed, parsedWindow{start: start, end: end, free: w.Free})+		}+	}++	sorted := append([]parsedWindow(nil), parsed...)+	sort.Slice(sorted, func(i, j int) bool { return sorted[i].start < sorted[j].start })+	for i := 1; i < len(sorted); i++ {+		if sorted[i].start < sorted[i-1].end {+			add(CodeBandOverlap, "windows %s-%s and %s-%s overlap",+				FormatBandTime(sorted[i-1].start), FormatBandTime(sorted[i-1].end),+				FormatBandTime(sorted[i].start), FormatBandTime(sorted[i].end))+			break+		}+	}++	freeCount := 0+	freeMinutes := 0+	for _, w := range parsed {+		if w.free {+			freeCount+++			freeMinutes += w.end - w.start+		}+	}+	if freeCount > 1 {+		add(CodeMultipleFreeBands, "a plan may have at most one free window, found %d", freeCount)+	}+	// AC 1.3: at least one rated band. The only way to have none is a free+	// window covering the whole day — a zero-width default remainder left by+	// rated windows tiling the rest is fine.+	if freeCount > 0 && freeMinutes >= minutesPerDay {+		add(CodeNoRatedBand, "the free window covers the whole day, leaving no rated band")+	}+	if freeCount > 0 && p.SavingsRefRate == nil {+		add(CodeSavingsRateMissing, "a plan with a free window requires a savings reference rate")+	}++	errs = append(errs, p.validateRates()...)+	return errs+}++// parsedWindow is a window whose boundaries have been resolved to minutes.+type parsedWindow struct {+	start, end int+	free       bool+}++// validateDates enforces the exclusive-end date rules: both dates must be+// real calendar dates and EndDate must be strictly after StartDate, since+// under exclusive ends EndDate == StartDate is a plan that prices no days.+func (p Plan) validateDates() []ValidationError {+	invalid := func(format string, args ...any) []ValidationError {+		return []ValidationError{{Code: CodeInvertedDates, Message: fmt.Sprintf(format, args...)}}+	}+	if !ValidDate(p.StartDate) {+		return invalid("startDate %q must be YYYY-MM-DD", p.StartDate)+	}+	if p.EndDate == "" {+		return nil+	}+	if !ValidDate(p.EndDate) {+		return invalid("endDate %q must be YYYY-MM-DD", p.EndDate)+	}+	if p.EndDate <= p.StartDate {+		return invalid("endDate %s must be after startDate %s", p.EndDate, p.StartDate)+	}+	return nil+}++// validateRates applies the shared bounds and precision rules (requirement+// 1.6) to every rate the plan carries. A free window's Rate is skipped — it+// carries no rate by contract.+func (p Plan) validateRates() []ValidationError {+	rates := []struct {+		label string+		value float64+	}{+		{"default rate", p.DefaultRate},+		{"feed-in rate", p.FeedInRate},+	}+	if p.SavingsRefRate != nil {+		rates = append(rates, struct {+			label string+			value float64+		}{"savings reference rate", *p.SavingsRefRate})+	}+	for i, w := range p.Windows {+		if w.Free {+			continue+		}+		rates = append(rates, struct {+			label string+			value float64+		}{fmt.Sprintf("window %d rate", i+1), w.Rate})+	}++	var errs []ValidationError+	for _, r := range rates {+		// 4 dp is exactly representable enough in float64 that scaling and+		// comparing against the nearest integer is safe (daily-costs+		// Decision 20).+		scaled := r.value * 10000+		if math.Abs(scaled-math.Round(scaled)) > 1e-6 {+			errs = append(errs, ValidationError{+				Code:    CodeRatePrecision,+				Message: fmt.Sprintf("%s must have at most 4 decimal places", r.label),+			})+		}+		if r.value < 0 || r.value > RateCap {+			errs = append(errs, ValidationError{+				Code:    CodeRateOutOfRange,+				Message: fmt.Sprintf("%s must be between 0 and %.2f AUD per kWh", r.label, RateCap),+			})+		}+	}+	return errs+}++// ValidDate reports whether s is a real calendar date in YYYY-MM-DD form.+// The structural check runs first so common malformed inputs fail without+// reaching the parser, and time.Parse rejects impossible dates like 02-30.+//+// Exported so the API handler validates plan dates with the same predicate+// Validate applies, rather than a parallel copy that could accept a date the+// domain then rejects.+func ValidDate(s string) bool {+	if len(s) != 10 || s[4] != '-' || s[7] != '-' {+		return false+	}+	_, err := time.Parse(dateLayout, s)+	return err == nil+}
internal/plan/property_test.go Added +274 / -0
diff --git a/internal/plan/property_test.go b/internal/plan/property_test.gonew file mode 100644index 0000000..24758ab--- /dev/null+++ b/internal/plan/property_test.go@@ -0,0 +1,274 @@+package plan_test++import (+	"fmt"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/derivedstats"+	"github.com/ArjenSchwarz/flux/internal/plan"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+	"pgregory.net/rapid"+)++// This file lives in package plan_test because the integral invariant below+// pairs the plan package with derivedstats. Both are leaf packages, so the+// pairing exists only in the test binary — plan itself still imports nothing+// from Flux.++var sydney = func() *time.Location {+	loc, err := time.LoadLocation("Australia/Sydney")+	if err != nil {+		panic(err)+	}+	return loc+}()++// genPlan draws a plan with 0–4 non-overlapping windows, at most one of them+// free. Windows are generated by walking the day forward so the result is+// always valid — the properties are about Segments, not about validation.+func genPlan(t *rapid.T) plan.Plan {+	n := rapid.IntRange(0, 4).Draw(t, "windowCount")+	freeIndex := rapid.IntRange(-1, n-1).Draw(t, "freeIndex")++	windows := make([]plan.Window, 0, n)+	cursor := 0+	for i := range n {+		// Leave room for the remaining windows so the walk never runs off+		// the end of the day.+		remaining := n - i+		gap := rapid.IntRange(0, 60).Draw(t, fmt.Sprintf("gap%d", i))+		start := cursor + gap+		maxStart := 24*60 - remaining+		if start > maxStart {+			break+		}+		width := rapid.IntRange(1, 24*60-start-(remaining-1)).Draw(t, fmt.Sprintf("width%d", i))+		windows = append(windows, plan.Window{+			Start: plan.FormatBandTime(start),+			End:   plan.FormatBandTime(start + width),+			Free:  i == freeIndex,+			Rate:  float64(rapid.IntRange(0, 10000).Draw(t, fmt.Sprintf("rate%d", i))) / 10000,+		})+		cursor = start + width+	}++	savings := 0.35+	return plan.Plan{+		StartDate:      "2026-01-01",+		DefaultRate:    float64(rapid.IntRange(0, 10000).Draw(t, "defaultRate")) / 10000,+		Windows:        windows,+		FeedInRate:     0.05,+		SavingsRefRate: &savings,+	}+}++// TestPropertySegmentsTileTheDay asserts the core invariant of AC 1.1: the+// derived segments are contiguous, non-overlapping, and cover 00:00–24:00+// exactly, whatever windows the plan carries.+func TestPropertySegmentsTileTheDay(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		segs := plan.Segments(genPlan(t))+		require.NotEmpty(t, segs)++		cursor := 0+		for i, seg := range segs {+			start, okStart := plan.ParseBandTime(seg.Start)+			end, okEnd := plan.ParseBandTime(seg.End)+			require.True(t, okStart, "segment %d start %q must parse", i, seg.Start)+			require.True(t, okEnd, "segment %d end %q must parse", i, seg.End)+			assert.Equal(t, cursor, start, "segment %d must abut its predecessor", i)+			assert.Greater(t, end, start, "segment %d must be non-empty", i)+			cursor = end+		}+		assert.Equal(t, 24*60, cursor, "segments must reach end of day")+	})+}++// TestPropertySegmentsPreserveWindows asserts that every window survives+// segmentation with its rate and free flag intact — the default rate only+// fills what the windows leave uncovered.+func TestPropertySegmentsPreserveWindows(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		p := genPlan(t)+		byRange := make(map[string]plan.Segment)+		for _, seg := range plan.Segments(p) {+			byRange[seg.Start+"-"+seg.End] = seg+		}+		for _, w := range p.Windows {+			seg, ok := byRange[w.Start+"-"+w.End]+			require.True(t, ok, "window %s-%s must appear as a segment", w.Start, w.End)+			assert.Equal(t, w.Free, seg.Free)+			if !w.Free {+				assert.Equal(t, w.Rate, seg.Rate)+			}+		}+	})+}++// TestPropertySegmentsNotMerged asserts Q26: abutting segments carrying the+// same rate stay separate, so the stored bandImports geometry is stable.+func TestPropertySegmentsNotMerged(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		p := genPlan(t)+		// Force every window onto the default rate so any merging would be+		// visible as a shorter segment list.+		for i := range p.Windows {+			p.Windows[i].Free = false+			p.Windows[i].Rate = p.DefaultRate+		}+		segs := plan.Segments(p)+		for _, w := range p.Windows {+			found := false+			for _, seg := range segs {+				if seg.Start == w.Start && seg.End == w.End {+					found = true+					break+				}+			}+			assert.True(t, found, "same-rate window %s-%s must stay its own segment", w.Start, w.End)+		}+	})+}++// genReadings draws readings spanning a full local calendar day, including+// the DST-length days, at 30–120 s spacing so the integrator's 60 s pair-gap+// rule sees a mixture of usable and skipped pairs.+func genReadings(t *rapid.T, day time.Time) []derivedstats.Reading {+	end := time.Date(day.Year(), day.Month(), day.Day()+1, 0, 0, 0, 0, sydney).Unix()+	readings := make([]derivedstats.Reading, 0, 3000)+	ts := day.Unix()+	for ts < end {+		readings = append(readings, derivedstats.Reading{+			Timestamp: ts,+			Pgrid:     rapid.Float64Range(-5000, 5000).Draw(t, fmt.Sprintf("pgrid%d", len(readings))),+		})+		ts += int64(rapid.IntRange(30, 120).Draw(t, fmt.Sprintf("gap%d", len(readings))))+	}+	return readings+}++// TestPropertySegmentIntegralsSumToWholeDay is the AC 3.8 invariant: summing+// the per-segment grid-import integrals reproduces the whole-day integral,+// because SegmentBounds derives shared wall-clock boundaries that cancel+// pairwise. It runs on ordinary, 23-hour, and 25-hour Sydney days — the case+// dayStart.Add(elapsed) arithmetic would get wrong.+func TestPropertySegmentIntegralsSumToWholeDay(t *testing.T) {+	dates := []string{"2026-04-12", "2026-04-05", "2026-10-04"}+	for _, date := range dates {+		t.Run(date, func(t *testing.T) {+			day, err := time.ParseInLocation("2006-01-02", date, sydney)+			require.NoError(t, err)+			rapid.Check(t, func(t *rapid.T) {+				p := genPlan(t)+				readings := genReadings(t, day)+				segs := plan.Segments(p)++				whole := plan.Segment{Start: "00:00", End: "24:00"}+				dayFrom, dayTo := plan.SegmentBounds(whole, day, sydney)+				total, ok := derivedstats.IntegrateOffpeakDeltas(readings, dayFrom, dayTo)+				if !ok {+					return+				}++				var summed float64+				for _, seg := range segs {+					from, to := plan.SegmentBounds(seg, day, sydney)+					part, partOK := derivedstats.IntegrateOffpeakDeltas(readings, from, to)+					if !partOK {+						// A segment too short to hold two construction points+						// contributes nothing; the whole-day integral loses the+						// same slice, so the comparison would be meaningless.+						return+					}+					summed += part.GridImportKwh+				}+				assert.InDelta(t, total.GridImportKwh, summed, 1e-9)+			})+		})+	}+}++// TestPropertyPlanForPicksExactlyOnePlan asserts AC 2.1 across a generated+// succession chain: consecutive plans whose end date is the next plan's start+// date price every day in the chain exactly once, with the switch day always+// going to the successor.+func TestPropertyPlanForPicksExactlyOnePlan(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		start, err := time.ParseInLocation("2006-01-02", "2026-01-01", sydney)+		require.NoError(t, err)++		n := rapid.IntRange(1, 5).Draw(t, "planCount")+		plans := make([]plan.Plan, 0, n)+		boundaries := []time.Time{start}+		cursor := start+		for i := range n {+			cursor = cursor.AddDate(0, 0, rapid.IntRange(1, 400).Draw(t, fmt.Sprintf("span%d", i)))+			boundaries = append(boundaries, cursor)+			p := plan.Plan{+				ID:        fmt.Sprintf("p%d", i),+				StartDate: boundaries[i].Format("2006-01-02"),+				EndDate:   cursor.Format("2006-01-02"),+			}+			plans = append(plans, p)+		}+		// The last plan is open-ended, matching the single-open-ended rule.+		plans[len(plans)-1].EndDate = ""++		for i, p := range plans {+			// A plan owns its own start date...+			got, ok := plan.PlanFor(plans, p.StartDate)+			require.True(t, ok, "plan %d start date must be priced", i)+			assert.Equal(t, p.ID, got.ID)++			// ...and the day before it belongs to the predecessor, never to it.+			eve := boundaries[i].AddDate(0, 0, -1).Format("2006-01-02")+			if prev, prevOK := plan.PlanFor(plans, eve); prevOK {+				assert.NotEqual(t, p.ID, prev.ID, "switch day eve must belong to the predecessor")+			}+		}++		// No day is priced twice: at most one plan covers any date.+		day := start+		for day.Before(cursor.AddDate(0, 0, 2)) {+			date := day.Format("2006-01-02")+			matches := 0+			for _, p := range plans {+				if p.Covers(date) {+					matches+++				}+			}+			assert.LessOrEqual(t, matches, 1, "date %s priced by %d plans", date, matches)+			day = day.AddDate(0, 0, rapid.IntRange(1, 90).Draw(t, "step"))+		}+	})+}++// TestPropertyFreeWindowMatchesSegments asserts FreeWindow and Segments agree:+// when a plan has a free band, the reported window is exactly the free+// segment's span.+func TestPropertyFreeWindowMatchesSegments(t *testing.T) {+	rapid.Check(t, func(t *rapid.T) {+		p := genPlan(t)+		p.StartDate = "2026-01-01"+		startMin, endMin, ok := plan.FreeWindow([]plan.Plan{p}, "2026-06-01")++		var free *plan.Segment+		for _, seg := range plan.Segments(p) {+			if seg.Free {+				free = &seg+				break+			}+		}+		if free == nil {+			assert.False(t, ok)+			return+		}+		require.True(t, ok)+		wantStart, _ := plan.ParseBandTime(free.Start)+		wantEnd, _ := plan.ParseBandTime(free.End)+		assert.Equal(t, wantStart, startMin)+		assert.Equal(t, wantEnd, endMin)+	})+}
internal/plan/segments_test.go Added +361 / -0
diff --git a/internal/plan/segments_test.go b/internal/plan/segments_test.gonew file mode 100644index 0000000..90c1063--- /dev/null+++ b/internal/plan/segments_test.go@@ -0,0 +1,361 @@+package plan++import (+	"testing"+	"time"++	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// sydney is the timezone every plan date and band boundary is interpreted in+// (Q19). Loaded once so the DST cases below exercise the real tzdata rules.+var sydney = func() *time.Location {+	loc, err := time.LoadLocation("Australia/Sydney")+	if err != nil {+		panic(err)+	}+	return loc+}()++// TestSegments pins the derived full-day segmentation. Segments must tile+// 00:00–24:00 exactly (AC 1.1) with the default rate filling everything the+// windows do not claim.+func TestSegments(t *testing.T) {+	t.Parallel()+	tests := map[string]struct {+		plan Plan+		want []Segment+	}{+		"no windows is one default segment": {+			plan: Plan{DefaultRate: 0.35},+			want: []Segment{{Start: "00:00", End: "24:00", Rate: 0.35}},+		},+		"current plan": {+			plan: currentPlan(),+			want: []Segment{+				{Start: "00:00", End: "11:00", Rate: 0.35},+				{Start: "11:00", End: "14:00", Free: true},+				{Start: "14:00", End: "24:00", Rate: 0.35},+			},+		},+		"new plan": {+			plan: newPlan(),+			want: []Segment{+				{Start: "00:00", End: "01:00", Rate: 0.35},+				{Start: "01:00", End: "06:00", Rate: 0.28},+				{Start: "06:00", End: "10:00", Rate: 0.35},+				{Start: "10:00", End: "15:00", Free: true},+				{Start: "15:00", End: "24:00", Rate: 0.35},+			},+		},+		"window at start of day leaves no leading default": {+			plan: Plan{DefaultRate: 0.35, Windows: []Window{{Start: "00:00", End: "06:00", Rate: 0.28}}},+			want: []Segment{+				{Start: "00:00", End: "06:00", Rate: 0.28},+				{Start: "06:00", End: "24:00", Rate: 0.35},+			},+		},+		"window at end of day leaves no trailing default": {+			plan: Plan{DefaultRate: 0.35, Windows: []Window{{Start: "18:00", End: "24:00", Rate: 0.28}}},+			want: []Segment{+				{Start: "00:00", End: "18:00", Rate: 0.35},+				{Start: "18:00", End: "24:00", Rate: 0.28},+			},+		},+		"windows tiling the day leave no zero-width remainders": {+			plan: Plan{DefaultRate: 0.35, Windows: []Window{+				{Start: "00:00", End: "12:00", Rate: 0.28},+				{Start: "12:00", End: "24:00", Rate: 0.31},+			}},+			want: []Segment{+				{Start: "00:00", End: "12:00", Rate: 0.28},+				{Start: "12:00", End: "24:00", Rate: 0.31},+			},+		},+		"unsorted windows are ordered by start": {+			plan: Plan{DefaultRate: 0.35, Windows: []Window{+				{Start: "18:00", End: "20:00", Rate: 0.40},+				{Start: "01:00", End: "06:00", Rate: 0.28},+			}},+			want: []Segment{+				{Start: "00:00", End: "01:00", Rate: 0.35},+				{Start: "01:00", End: "06:00", Rate: 0.28},+				{Start: "06:00", End: "18:00", Rate: 0.35},+				{Start: "18:00", End: "20:00", Rate: 0.40},+				{Start: "20:00", End: "24:00", Rate: 0.35},+			},+		},+		// Q26: abutting same-rate segments are NOT merged. Stable geometry is+		// what makes the stored bandImports join deterministic.+		"abutting same-rate windows are not merged": {+			plan: Plan{DefaultRate: 0.35, Windows: []Window{+				{Start: "00:00", End: "06:00", Rate: 0.35},+				{Start: "06:00", End: "12:00", Rate: 0.35},+			}},+			want: []Segment{+				{Start: "00:00", End: "06:00", Rate: 0.35},+				{Start: "06:00", End: "12:00", Rate: 0.35},+				{Start: "12:00", End: "24:00", Rate: 0.35},+			},+		},+		"window rate equal to the default is still its own segment": {+			plan: Plan{DefaultRate: 0.35, Windows: []Window{{Start: "10:00", End: "12:00", Rate: 0.35}}},+			want: []Segment{+				{Start: "00:00", End: "10:00", Rate: 0.35},+				{Start: "10:00", End: "12:00", Rate: 0.35},+				{Start: "12:00", End: "24:00", Rate: 0.35},+			},+		},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			assert.Equal(t, tc.want, Segments(tc.plan))+		})+	}+}++// TestRatedSegments pins the rated-only view stored in bandImports — the free+// band's import lives on the flux-offpeak row instead (Q31).+func TestRatedSegments(t *testing.T) {+	t.Parallel()+	assert.Equal(t, []Segment{+		{Start: "00:00", End: "01:00", Rate: 0.35},+		{Start: "01:00", End: "06:00", Rate: 0.28},+		{Start: "06:00", End: "10:00", Rate: 0.35},+		{Start: "15:00", End: "24:00", Rate: 0.35},+	}, RatedSegments(newPlan()))+}++// TestCovers pins the exclusive end-date semantics of Decision 5.+func TestCovers(t *testing.T) {+	t.Parallel()+	tests := map[string]struct {+		plan Plan+		date string+		want bool+	}{+		"open-ended covers its start date":  {plan: Plan{StartDate: "2026-01-01"}, date: "2026-01-01", want: true},+		"open-ended covers the far future":  {plan: Plan{StartDate: "2026-01-01"}, date: "2099-12-31", want: true},+		"open-ended excludes earlier dates": {plan: Plan{StartDate: "2026-01-01"}, date: "2025-12-31", want: false},+		"closed covers its start date":      {plan: Plan{StartDate: "2026-01-01", EndDate: "2026-08-01"}, date: "2026-01-01", want: true},+		"closed covers the day before its end": {plan: Plan{StartDate: "2026-01-01", EndDate: "2026-08-01"},+			date: "2026-07-31", want: true},+		"closed excludes its end date": {plan: Plan{StartDate: "2026-01-01", EndDate: "2026-08-01"},+			date: "2026-08-01", want: false},+		"closed excludes later dates": {plan: Plan{StartDate: "2026-01-01", EndDate: "2026-08-01"},+			date: "2026-08-02", want: false},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			assert.Equal(t, tc.want, tc.plan.Covers(tc.date))+		})+	}+}++// switchDayPlans is the succession pair from AC 2.2: the predecessor's+// exclusive end date is the successor's start date, both literally 2026-08-01.+func switchDayPlans() []Plan {+	pred := currentPlan()+	pred.EndDate = "2026-08-01"+	return []Plan{pred, newPlan()}+}++// TestPlanFor covers AC 2.1/2.2 — every day is priced by at most one plan and+// the switch day belongs to the successor.+func TestPlanFor(t *testing.T) {+	t.Parallel()+	plans := switchDayPlans()+	tests := map[string]struct {+		date   string+		wantID string // empty => no plan covers the date+	}{+		"well inside the predecessor": {date: "2026-05-01", wantID: "current"},+		"switch day eve":              {date: "2026-07-31", wantID: "current"},+		"switch day":                  {date: "2026-08-01", wantID: "successor"},+		"day after the switch":        {date: "2026-08-02", wantID: "successor"},+		"before any plan":             {date: "2025-12-31"},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			got, ok := PlanFor(plans, tc.date)+			if tc.wantID == "" {+				assert.False(t, ok)+				return+			}+			require.True(t, ok)+			assert.Equal(t, tc.wantID, got.ID)+		})+	}+}++// TestPlanForGapBetweenPlans covers AC 2.4/2.7: ending the open-ended plan+// without a successor leaves the days after it unpriced.+func TestPlanForGapBetweenPlans(t *testing.T) {+	t.Parallel()+	closed := currentPlan()+	closed.EndDate = "2026-08-01"+	_, ok := PlanFor([]Plan{closed}, "2026-08-01")+	assert.False(t, ok)+}++// TestFreeWindow covers AC 4.1/4.2 — the free window comes from the plan+// pricing the day in question, switching automatically on the switch date.+func TestFreeWindow(t *testing.T) {+	t.Parallel()+	plans := switchDayPlans()+	noFree := Plan{ID: "flat", StartDate: "2026-01-01", DefaultRate: 0.35}++	tests := map[string]struct {+		plans     []Plan+		date      string+		wantStart int+		wantEnd   int+		wantOK    bool+	}{+		"predecessor window":     {plans: plans, date: "2026-07-31", wantStart: 660, wantEnd: 840, wantOK: true},+		"successor window":       {plans: plans, date: "2026-08-01", wantStart: 600, wantEnd: 900, wantOK: true},+		"no plan covers the day": {plans: plans, date: "2025-01-01"},+		"plan has no free band":  {plans: []Plan{noFree}, date: "2026-05-01"},+		"empty plan set":         {plans: nil, date: "2026-05-01"},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			start, end, ok := FreeWindow(tc.plans, tc.date)+			assert.Equal(t, tc.wantOK, ok)+			if tc.wantOK {+				assert.Equal(t, tc.wantStart, start)+				assert.Equal(t, tc.wantEnd, end)+			}+		})+	}+}++// TestFreeWindowMinutes pins the per-plan accessor the package-level+// FreeWindow builds on.+func TestFreeWindowMinutes(t *testing.T) {+	t.Parallel()+	start, end, ok := currentPlan().FreeWindowMinutes()+	require.True(t, ok)+	assert.Equal(t, 660, start)+	assert.Equal(t, 840, end)++	_, _, ok = Plan{DefaultRate: 0.35}.FreeWindowMinutes()+	assert.False(t, ok)+}++// TestSegmentBounds pins wall-clock boundary resolution (AC 3.8). Boundaries+// must come from time.Date on the local calendar day — NOT from+// dayStart.Add(elapsed), which is an hour off on DST days.+func TestSegmentBounds(t *testing.T) {+	t.Parallel()+	tests := map[string]struct {+		date     string+		segment  Segment+		wantFrom string // RFC3339 in Sydney local time+		wantTo   string+	}{+		"ordinary day": {+			date:     "2026-04-12",+			segment:  Segment{Start: "11:00", End: "14:00"},+			wantFrom: "2026-04-12T11:00:00+10:00",+			wantTo:   "2026-04-12T14:00:00+10:00",+		},+		"whole ordinary day": {+			date:     "2026-04-12",+			segment:  Segment{Start: "00:00", End: "24:00"},+			wantFrom: "2026-04-12T00:00:00+10:00",+			wantTo:   "2026-04-13T00:00:00+10:00",+		},+		// 2026-04-05 is Sydney's 25-hour day (clocks go back 03:00 → 02:00).+		"25-hour day spans midnight to midnight": {+			date:     "2026-04-05",+			segment:  Segment{Start: "00:00", End: "24:00"},+			wantFrom: "2026-04-05T00:00:00+11:00",+			wantTo:   "2026-04-06T00:00:00+10:00",+		},+		"25-hour day afternoon segment is unaffected": {+			date:     "2026-04-05",+			segment:  Segment{Start: "11:00", End: "14:00"},+			wantFrom: "2026-04-05T11:00:00+10:00",+			wantTo:   "2026-04-05T14:00:00+10:00",+		},+		// 2026-10-04 is Sydney's 23-hour day (clocks go forward 02:00 → 03:00).+		"23-hour day spans midnight to midnight": {+			date:     "2026-10-04",+			segment:  Segment{Start: "00:00", End: "24:00"},+			wantFrom: "2026-10-04T00:00:00+10:00",+			wantTo:   "2026-10-05T00:00:00+11:00",+		},+		"23-hour day afternoon segment is unaffected": {+			date:     "2026-10-04",+			segment:  Segment{Start: "10:00", End: "15:00"},+			wantFrom: "2026-10-04T10:00:00+11:00",+			wantTo:   "2026-10-04T15:00:00+11:00",+		},+	}+	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			t.Parallel()+			day, err := time.ParseInLocation(dateLayout, tc.date, sydney)+			require.NoError(t, err)+			from, to := SegmentBounds(tc.segment, day, sydney)+			assert.Equal(t, tc.wantFrom, time.Unix(from, 0).In(sydney).Format(time.RFC3339))+			assert.Equal(t, tc.wantTo, time.Unix(to, 0).In(sydney).Format(time.RFC3339))+		})+	}+}++// TestSegmentBoundsDSTGapIsDeterministic pins the design's position on a+// boundary landing inside the skipped or repeated DST hour: whichever+// occurrence time.Date picks is fine, as long as it is stable. The sum+// invariant of AC 3.8 holds either way because adjacent segments share the+// boundary.+func TestSegmentBoundsDSTGapIsDeterministic(t *testing.T) {+	t.Parallel()+	for _, date := range []string{"2026-10-04", "2026-04-05"} {+		t.Run(date, func(t *testing.T) {+			t.Parallel()+			day, err := time.ParseInLocation(dateLayout, date, sydney)+			require.NoError(t, err)+			seg := Segment{Start: "02:30", End: "04:00"}+			from1, to1 := SegmentBounds(seg, day, sydney)+			from2, to2 := SegmentBounds(seg, day, sydney)+			assert.Equal(t, from1, from2)+			assert.Equal(t, to1, to2)+			assert.Less(t, from1, to1)+		})+	}+}++// TestSegmentBoundsTilesTheDay is the invariant the per-segment integrals+// depend on: consecutive segment bounds abut exactly and the first/last reach+// local midnight, on ordinary and DST-length days alike.+func TestSegmentBoundsTilesTheDay(t *testing.T) {+	t.Parallel()+	for _, date := range []string{"2026-04-12", "2026-04-05", "2026-10-04"} {+		t.Run(date, func(t *testing.T) {+			t.Parallel()+			day, err := time.ParseInLocation(dateLayout, date, sydney)+			require.NoError(t, err)+			segs := Segments(newPlan())++			var prevEnd int64+			for i, seg := range segs {+				from, to := SegmentBounds(seg, day, sydney)+				if i == 0 {+					assert.Equal(t, day.Unix(), from, "first segment starts at local midnight")+				} else {+					assert.Equal(t, prevEnd, from, "segment %d abuts its predecessor", i)+				}+				prevEnd = to+			}+			nextMidnight := time.Date(day.Year(), day.Month(), day.Day()+1, 0, 0, 0, 0, sydney)+			assert.Equal(t, nextMidnight.Unix(), prevEnd, "last segment ends at the next local midnight")+		})+	}+}
internal/plan/segments.go Added +170 / -0
diff --git a/internal/plan/segments.go b/internal/plan/segments.gonew file mode 100644index 0000000..c995d66--- /dev/null+++ b/internal/plan/segments.go@@ -0,0 +1,170 @@+package plan++import (+	"sort"+	"time"+)++// Segment is one band of the derived full-day segmentation: a half-open+// [Start, End) slice of the day carrying either a rate or the free marker.+// The segments of a plan tile 00:00–24:00 exactly (AC 1.1).+type Segment struct {+	Start string+	End   string+	Free  bool+	Rate  float64+}++// Segments derives the plan's contiguous full-day band list from its default+// rate and exception windows. The result always starts at "00:00", ends at+// "24:00", and contains no gaps, overlaps, or zero-width entries.+//+// Abutting segments carrying the same rate are deliberately not merged+// (Q26): the stored per-band split joins to this geometry, so it has to stay+// stable against a merge that would depend on rate equality.+//+// Windows that fail to parse are skipped. Validate is the place that reports+// them; producing a partial segmentation here keeps callers from having to+// handle an error on what is otherwise a total function.+func Segments(p Plan) []Segment {+	type band struct {+		start, end int+		free       bool+		rate       float64+	}+	bands := make([]band, 0, len(p.Windows))+	for _, w := range p.Windows {+		start, okStart := ParseBandTime(w.Start)+		end, okEnd := ParseBandTime(w.End)+		if !okStart || !okEnd || start >= end {+			continue+		}+		bands = append(bands, band{start: start, end: end, free: w.Free, rate: w.Rate})+	}+	sort.SliceStable(bands, func(i, j int) bool { return bands[i].start < bands[j].start })++	segments := make([]Segment, 0, len(bands)*2+1)+	appendDefault := func(from, to int) {+		if from >= to {+			return+		}+		segments = append(segments, Segment{+			Start: FormatBandTime(from),+			End:   FormatBandTime(to),+			Rate:  p.DefaultRate,+		})+	}++	cursor := 0+	for _, b := range bands {+		// A window overlapping its predecessor is invalid (Validate reports+		// band_overlap); clamping keeps the tiling invariant intact rather+		// than emitting an inverted segment.+		if b.start < cursor {+			continue+		}+		appendDefault(cursor, b.start)+		seg := Segment{Start: FormatBandTime(b.start), End: FormatBandTime(b.end), Free: b.free}+		if !b.free {+			seg.Rate = b.rate+		}+		segments = append(segments, seg)+		cursor = b.end+	}+	appendDefault(cursor, minutesPerDay)+	return segments+}++// RatedSegments is Segments filtered to the non-free bands. This is the+// geometry stored in a day's bandImports — free-window import lives on the+// flux-offpeak row, which owns it exclusively (Q31).+func RatedSegments(p Plan) []Segment {+	all := Segments(p)+	rated := make([]Segment, 0, len(all))+	for _, seg := range all {+		if !seg.Free {+			rated = append(rated, seg)+		}+	}+	return rated+}++// Covers reports whether the plan prices the given YYYY-MM-DD date. The end+// date is exclusive (Decision 5), so a plan ending on the date its successor+// starts hands that day to the successor. Lexicographic comparison is correct+// because YYYY-MM-DD sorts chronologically.+func (p Plan) Covers(date string) bool {+	if date < p.StartDate {+		return false+	}+	return p.EndDate == "" || date < p.EndDate+}++// PlanFor returns the plan pricing the given date. At most one plan covers+// any date (AC 2.1) — the validation rules make overlapping ranges+// unstorable — so the first match is the only match.+func PlanFor(plans []Plan, date string) (Plan, bool) {+	for _, p := range plans {+		if p.Covers(date) {+			return p, true+		}+	}+	return Plan{}, false+}++// FreeWindowMinutes returns the plan's free band as minute-of-day bounds.+// ok is false when the plan has no free window — the caller then behaves as+// it does when no off-peak data exists (AC 4.4), never substituting a default+// window.+func (p Plan) FreeWindowMinutes() (startMin, endMin int, ok bool) {+	for _, w := range p.Windows {+		if !w.Free {+			continue+		}+		start, okStart := ParseBandTime(w.Start)+		end, okEnd := ParseBandTime(w.End)+		if !okStart || !okEnd || start >= end {+			return 0, 0, false+		}+		return start, end, true+	}+	return 0, 0, false+}++// FreeWindow returns the free window of the plan pricing the given date+// (AC 4.1). ok is false when no plan covers the date or the covering plan has+// no free band — the two "no window" outcomes callers treat alike.+func FreeWindow(plans []Plan, date string) (startMin, endMin int, ok bool) {+	p, found := PlanFor(plans, date)+	if !found {+		return 0, 0, false+	}+	return p.FreeWindowMinutes()+}++// SegmentBounds resolves a segment to absolute Unix bounds on the given local+// calendar day. Boundaries come from time.Date on the day's wall clock, so+// band membership follows local time across DST transitions (AC 3.8) and+// per-segment integrals over a 23- or 25-hour day still sum to the whole-day+// integral — adjacent segments share a boundary, so the shared points cancel.+//+// Deriving boundaries by adding elapsed minutes to midnight would be an hour+// off on DST days; this helper exists so no caller has to remember that.+//+// A boundary landing inside the skipped or repeated DST hour resolves to+// whichever occurrence time.Date picks. That is deterministic, which is all+// the sum invariant needs, and no real plan has a boundary in 02:00–03:00.+func SegmentBounds(seg Segment, day time.Time, loc *time.Location) (startUnix, endUnix int64) {+	local := day.In(loc)+	at := func(hhmm string) int64 {+		minutes, ok := ParseBandTime(hhmm)+		if !ok {+			minutes = 0+		}+		// time.Date normalises out-of-range fields, so 24:00 becomes the next+		// day's midnight — DST-correctly, because the normalisation happens+		// in the location.+		return time.Date(local.Year(), local.Month(), local.Day(), minutes/60, minutes%60, 0, 0, loc).Unix()+	}+	return at(seg.Start), at(seg.End)+}
internal/poller/dailysummary_peak_test.go Modified +1 / -0
diff --git a/internal/poller/dailysummary_peak_test.go b/internal/poller/dailysummary_peak_test.goindex 6487fdb..49cedeb 100644--- a/internal/poller/dailysummary_peak_test.go+++ b/internal/poller/dailysummary_peak_test.go@@ -64,6 +64,7 @@ func TestSummarisation_PeakSkippedWhenBothSentinelsSet(t *testing.T) { 			SysSn: "TEST123", Date: "2026-04-14", 			DerivedStatsComputedAt: "2026-04-14T22:00:00Z", 			PeakComputedAt:         "2026-04-14T22:00:00Z",+			BandsComputedAt:        "2026-04-14T22:00:00Z", 		}, 	} 	p, _ := summarisationFixturePoller(t, ms)
internal/poller/dailysummary_plan_test.go Added +303 / -0
diff --git a/internal/poller/dailysummary_plan_test.go b/internal/poller/dailysummary_plan_test.gonew file mode 100644index 0000000..a24b172--- /dev/null+++ b/internal/poller/dailysummary_plan_test.go@@ -0,0 +1,303 @@+package poller++import (+	"context"+	"errors"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/config"+	"github.com/ArjenSchwarz/flux/internal/derivedstats"+	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// These tests cover the per-outcome gating table in design.md: what runs, what+// sentinels are set, and what the pass reports, for each of the four ways plan+// resolution can turn out. The old single early-return on an unresolved window+// would have starved socLow, dailyUsage, peak, and bands forever on any day+// without one (Q33).++// summarisationPollerWith builds the fixture poller against a specific plan+// source, so each outcome in the table can be exercised in isolation.+func summarisationPollerWith(t *testing.T, ms *mockStore, lister PlanLister) *Poller {+	t.Helper()+	loc, _ := time.LoadLocation("Australia/Sydney")+	p := New(nil, ms, lister, &config.Config{Serial: "TEST123", Location: loc})+	p.plans.retryDelay = time.Microsecond+	p.now = func() time.Time { return time.Date(2026, 4, 15, 2, 0, 0, 0, loc) }+	p.metrics = &fakeMetrics{}+	return p+}++// gridReadings builds a day of constant grid import at 60 s cadence, closing+// on the next local midnight so every band has a right bracket.+func gridReadings(date string, loc *time.Location, watts float64) []dynamo.ReadingItem {+	dayStart, _ := time.ParseInLocation("2006-01-02", date, loc)+	dayEnd := dayStart.AddDate(0, 0, 1)+	out := make([]dynamo.ReadingItem, 0, 24*60+1)+	for ts := dayStart; !ts.After(dayEnd); ts = ts.Add(time.Minute) {+		out = append(out, dynamo.ReadingItem{+			SysSn: "TEST123", Timestamp: ts.Unix(), Pgrid: watts, Soc: 50,+		})+	}+	return out+}++func touPlanLister() PlanLister {+	savings := 0.35+	rate := 0.28+	return &mockPlanLister{responses: []planListerResponse{{items: []dynamo.PricingItem{{+		PricingID: "tou", StartDate: "2000-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+		Windows: []dynamo.PricingWindow{+			{Start: "10:00", End: "15:00", Free: true},+			{Start: "01:00", End: "06:00", Rate: &rate},+		},+		SavingsReferenceRate: &savings,+	}}}}}+}++func noFreeBandPlanLister() PlanLister {+	return &mockPlanLister{responses: []planListerResponse{{items: []dynamo.PricingItem{{+		PricingID: "flat", StartDate: "2000-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+	}}}}}+}++// --- Outcome 1: plan read failed with no cache ---++// AC 4.6 / Q14: an unreadable pricing table is transient. Setting sentinels on+// this path would make the day terminal on the strength of an infra blip, so+// nothing is written and the pass reports an error for the next tick to retry.+func TestSummarisation_PlanReadFailure_WritesNothingAndRetries(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{SysSn: "TEST123", Date: "2026-04-14"},+		queryReadingsResult:  gridReadings("2026-04-14", loc, 1000),+	}+	p := summarisationPollerWith(t, ms, failingPlanLister())++	result := p.runSummarisationPass(context.Background(), "2026-04-14")++	assert.Equal(t, PassResultError, result)+	assert.Zero(t, ms.derivedUpdates, "a plan read failure must not set any sentinel")+}++// --- Outcome 2: plan with a free band ---++func TestSummarisation_PlanWithFreeBand_RunsEveryBlock(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{SysSn: "TEST123", Date: "2026-04-14"},+		queryReadingsResult:  gridReadings("2026-04-14", loc, 1000),+	}+	p := summarisationPollerWith(t, ms, touPlanLister())++	require.Equal(t, PassResultSuccess, p.runSummarisationPass(context.Background(), "2026-04-14"))+	require.NotNil(t, ms.lastDerived)++	assert.NotEmpty(t, ms.lastDerived.DerivedStatsComputedAt)+	assert.NotEmpty(t, ms.lastDerived.PeakComputedAt)+	assert.NotEmpty(t, ms.lastDerived.BandsComputedAt)+	assert.NotNil(t, ms.lastDerived.SocLow)+	assert.NotNil(t, ms.lastDerived.DailyUsage)++	// The split's geometry is the plan's rated segments — the free band is+	// absent because the flux-offpeak row owns that import (Q31).+	assert.Equal(t, []dynamo.BandImportAttr{+		{Start: "00:00", End: "01:00", Kwh: 1.0},+		{Start: "01:00", End: "06:00", Kwh: 5.0},+		{Start: "06:00", End: "10:00", Kwh: 4.0},+		{Start: "15:00", End: "24:00", Kwh: 9.0},+	}, ms.lastDerived.BandImports)+}++// Data Consistency: peakGridImportKwh is import outside the free window, which+// is exactly the sum of the rated bands. Deriving both from one integration is+// what makes the two agree by construction rather than by coincidence.+func TestSummarisation_PeakEqualsSumOfRatedBands(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{SysSn: "TEST123", Date: "2026-04-14"},+		queryReadingsResult:  gridReadings("2026-04-14", loc, 1000),+	}+	p := summarisationPollerWith(t, ms, touPlanLister())++	require.Equal(t, PassResultSuccess, p.runSummarisationPass(context.Background(), "2026-04-14"))+	require.NotNil(t, ms.lastDerived.PeakGridImportKwh)++	var sum float64+	for _, b := range ms.lastDerived.BandImports {+		sum += b.Kwh+	}+	assert.InDelta(t, sum, *ms.lastDerived.PeakGridImportKwh, 0.01)+	// 24 h at 1 kW less the 5 h free window.+	assert.InDelta(t, 19.0, *ms.lastDerived.PeakGridImportKwh, 0.01)+}++// --- Outcome 3: plan without a free band ---++// The window-dependent values are absent rather than zero (AC 4.4), but every+// window-independent block still runs and the whole day is rated.+func TestSummarisation_PlanWithoutFreeBand_WholeDayRated(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{SysSn: "TEST123", Date: "2026-04-14"},+		queryReadingsResult:  gridReadings("2026-04-14", loc, 1000),+	}+	p := summarisationPollerWith(t, ms, noFreeBandPlanLister())++	require.Equal(t, PassResultSuccess, p.runSummarisationPass(context.Background(), "2026-04-14"))+	require.NotNil(t, ms.lastDerived)++	assert.NotEmpty(t, ms.lastDerived.DerivedStatsComputedAt)+	assert.NotEmpty(t, ms.lastDerived.PeakComputedAt)+	assert.NotEmpty(t, ms.lastDerived.BandsComputedAt)+	assert.NotNil(t, ms.lastDerived.SocLow)++	require.Len(t, ms.lastDerived.BandImports, 1)+	assert.Equal(t, "00:00", ms.lastDerived.BandImports[0].Start)+	assert.Equal(t, "24:00", ms.lastDerived.BandImports[0].End)+	require.NotNil(t, ms.lastDerived.PeakGridImportKwh)+	assert.InDelta(t, 24.0, *ms.lastDerived.PeakGridImportKwh, 0.01)++	// No off-peak block: there is no free window to carve one out of.+	require.NotNil(t, ms.lastDerived.DailyUsage)+	for _, b := range ms.lastDerived.DailyUsage.Blocks {+		assert.NotEqual(t, derivedstats.DailyUsageKindOffPeak, b.Kind)+	}+}++// --- Outcome 4: no plan covers the date ---++// Only window-independent work runs. The band sentinel stays unset so an+// explicit backfill can still capture the split once a plan exists — within+// the readings TTL, after which the day is terminal.+func TestSummarisation_NoPlan_RunsWindowIndependentStatsOnly(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{SysSn: "TEST123", Date: "2026-04-14"},+		queryReadingsResult:  gridReadings("2026-04-14", loc, 1000),+	}+	p := summarisationPollerWith(t, ms, &mockPlanLister{responses: []planListerResponse{{items: nil}}})++	require.Equal(t, PassResultSuccess, p.runSummarisationPass(context.Background(), "2026-04-14"))+	require.NotNil(t, ms.lastDerived)++	assert.NotNil(t, ms.lastDerived.SocLow, "socLow needs no window")+	assert.Nil(t, ms.lastDerived.DailyUsage, "block layout needs a window")+	assert.Empty(t, ms.lastDerived.PeakPeriods, "peak periods need a window")+	assert.NotEmpty(t, ms.lastDerived.DerivedStatsComputedAt, "socLow must be persisted")+	assert.Empty(t, ms.lastDerived.PeakComputedAt, "peak is undefined without a plan")+	assert.Empty(t, ms.lastDerived.BandsComputedAt, "the band sentinel stays unset for backfill")+	assert.Nil(t, ms.lastDerived.BandImports)+}++// Once the window-independent stats are written, a date no plan prices has+// nothing left the pass can compute — the peak and band groups both need a+// plan, and their sentinels stay unset on purpose so a backfill can still+// repair the day. Without the gate the pass would re-query a full day of+// readings every hour to compute nothing, so the outcome must be a skip, and a+// distinct one: an operator watching the metric needs to see dates going+// unpriced.+func TestSummarisation_NoPlan_SkipsOnceWindowIndependentStatsExist(t *testing.T) {+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{+			SysSn: "TEST123", Date: "2026-04-14",+			DerivedStatsComputedAt: "2026-04-15T01:00:00Z",+		},+		// A readings query on this date is the failure being guarded against, so+		// make one impossible to miss: reaching it would return+		// PassResultError instead of the skip.+		queryReadingsErr: errors.New("readings must not be queried for an unpriceable date"),+	}+	p := summarisationPollerWith(t, ms, &mockPlanLister{responses: []planListerResponse{{items: nil}}})++	assert.Equal(t, PassResultSkippedNoPlan, p.runSummarisationPass(context.Background(), "2026-04-14"))+	assert.Nil(t, ms.lastDerived, "nothing to write")+}++// --- Sentinel gating across the three groups ---++func TestSummarisation_BandGroupGatedOnItsOwnSentinel(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{+			SysSn: "TEST123", Date: "2026-04-14",+			DerivedStatsComputedAt: "2026-04-15T00:30:00Z",+			PeakComputedAt:         "2026-04-15T00:30:00Z",+		},+		queryReadingsResult: gridReadings("2026-04-14", loc, 1000),+	}+	p := summarisationPollerWith(t, ms, touPlanLister())++	require.Equal(t, PassResultSuccess, p.runSummarisationPass(context.Background(), "2026-04-14"))+	require.NotNil(t, ms.lastDerived)++	assert.NotEmpty(t, ms.lastDerived.BandsComputedAt, "the missing group is computed")+	assert.Empty(t, ms.lastDerived.DerivedStatsComputedAt, "an already-computed group is not rewritten")+	assert.Empty(t, ms.lastDerived.PeakComputedAt)+}++func TestSummarisation_AllThreeSentinelsPresent_SkipsEntirely(t *testing.T) {+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{+			SysSn: "TEST123", Date: "2026-04-14",+			DerivedStatsComputedAt: "2026-04-15T00:30:00Z",+			PeakComputedAt:         "2026-04-15T00:30:00Z",+			BandsComputedAt:        "2026-04-15T00:30:00Z",+		},+	}+	p := summarisationPollerWith(t, ms, touPlanLister())++	assert.Equal(t, PassResultSkippedAlreadyDone, p.runSummarisationPass(context.Background(), "2026-04-14"))+	assert.Zero(t, ms.derivedUpdates)+}++// Mirrors PeakGridImportKwh's contract: a split the integrator cannot produce+// leaves the value absent but still sets the sentinel, so the row is not+// re-attempted every hour for the rest of the day.+func TestSummarisation_BandUsabilityGateLeavesSplitAbsent(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	// Readings start at 06:00, so the 00:00–01:00 and 01:00–06:00 rated+	// segments have nothing to integrate.+	readings := gridReadings("2026-04-14", loc, 1000)[6*60:]+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{SysSn: "TEST123", Date: "2026-04-14"},+		queryReadingsResult:  readings,+	}+	p := summarisationPollerWith(t, ms, touPlanLister())++	require.Equal(t, PassResultSuccess, p.runSummarisationPass(context.Background(), "2026-04-14"))+	require.NotNil(t, ms.lastDerived)++	assert.Nil(t, ms.lastDerived.BandImports, "a partially known split is unavailable, not partial")+	assert.NotEmpty(t, ms.lastDerived.BandsComputedAt, "the sentinel is still set so the row is not retried")+	assert.Nil(t, ms.lastDerived.PeakGridImportKwh, "peak shares the split's usability gate")+	assert.NotEmpty(t, ms.lastDerived.PeakComputedAt)+}++// AC 3.8: band membership follows wall-clock time, so on the 23-hour DST day+// the bands keep their local boundaries and the energies follow real elapsed+// time. Computing boundaries as midnight-plus-elapsed would shift them an hour.+func TestSummarisation_DSTDayBandsFollowWallClock(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	const date = "2026-10-04" // DST start: 02:00 → 03:00+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{SysSn: "TEST123", Date: date},+		queryReadingsResult:  gridReadings(date, loc, 1000),+	}+	p := summarisationPollerWith(t, ms, touPlanLister())++	require.Equal(t, PassResultSuccess, p.runSummarisationPass(context.Background(), date))+	require.NotNil(t, ms.lastDerived)+	require.Len(t, ms.lastDerived.BandImports, 4)++	assert.Equal(t, "01:00", ms.lastDerived.BandImports[1].Start)+	assert.Equal(t, "06:00", ms.lastDerived.BandImports[1].End)+	// 01:00–06:00 local spans only four real hours on this day.+	assert.InDelta(t, 4.0, ms.lastDerived.BandImports[1].Kwh, 0.01)+	// 23-hour day less the 5-hour free window.+	require.NotNil(t, ms.lastDerived.PeakGridImportKwh)+	assert.InDelta(t, 18.0, *ms.lastDerived.PeakGridImportKwh, 0.05)+}
internal/poller/dailysummary_test.go Modified +9 / -21
diff --git a/internal/poller/dailysummary_test.go b/internal/poller/dailysummary_test.goindex f203833..d78f8f6 100644--- a/internal/poller/dailysummary_test.go+++ b/internal/poller/dailysummary_test.go@@ -27,13 +27,12 @@ func summarisationFixturePoller(t *testing.T, ms *mockStore) (*Poller, *fakeMetr 	t.Helper() 	loc, _ := time.LoadLocation("Australia/Sydney") 	cfg := &config.Config{-		Serial:       "TEST123",-		Location:     loc,-		OffpeakStart: 11 * time.Hour,-		OffpeakEnd:   14 * time.Hour,+		Serial:   "TEST123",+		Location: loc, 	} 	fakeM := &fakeMetrics{}-	p := New(nil, ms, cfg)+	p := New(nil, ms, openEndedPlanLister("11:00", "14:00"), cfg)+	p.plans.retryDelay = time.Microsecond 	// Pin clock to 2026-04-15 02:00 AEST so "yesterday" deterministically 	// resolves to 2026-04-14. 	p.now = func() time.Time { return time.Date(2026, 4, 15, 2, 0, 0, 0, loc) }@@ -116,6 +115,7 @@ func TestSummarisation_AlreadyPopulated(t *testing.T) { 			SysSn: "TEST123", Date: "2026-04-14", 			DerivedStatsComputedAt: "2026-04-14T22:00:00Z", 			PeakComputedAt:         "2026-04-14T22:00:00Z",+			BandsComputedAt:        "2026-04-14T22:00:00Z", 		}, 	} 	p, _ := summarisationFixturePoller(t, ms)@@ -127,22 +127,9 @@ func TestSummarisation_AlreadyPopulated(t *testing.T) { 	assert.Nil(t, ms.queryReadingsResult, "queryReadingsResult unset means QueryReadings must not have been called for default") } -func TestSummarisation_SsmUnresolved(t *testing.T) {-	loc, _ := time.LoadLocation("Australia/Sydney")-	ms := &mockStore{-		getDailyEnergyResult: &dynamo.DailyEnergyItem{SysSn: "TEST123", Date: "2026-04-14"},-		queryReadingsResult:  makeReadings("2026-04-14", loc),-	}-	p, _ := summarisationFixturePoller(t, ms)-	// Force off-peak window to invalid by zeroing the durations after Pollerm-	// is built (cfg.OffpeakStart >= cfg.OffpeakEnd → ParseOffpeakWindow returns false).-	p.cfg.OffpeakStart = 0-	p.cfg.OffpeakEnd = 0--	result := p.runSummarisationPass(context.Background(), "2026-04-14")-	assert.Equal(t, PassResultSkippedSSMUnresolved, result)-	assert.Zero(t, ms.derivedUpdates)-}+// The unresolved-window early return is gone; its replacements — the four+// per-outcome paths of the plan-resolution table — live in+// dailysummary_plan_test.go.  func TestSummarisation_ReadingsError(t *testing.T) { 	ms := &mockStore{@@ -301,6 +288,7 @@ func TestSummarisation_PrecheckShortCircuits_NoReadingsQuery(t *testing.T) { 			SysSn: "TEST123", Date: "2026-04-14", 			DerivedStatsComputedAt: "2026-04-14T22:00:00Z", 			PeakComputedAt:         "2026-04-14T22:00:00Z",+			BandsComputedAt:        "2026-04-14T22:00:00Z", 		}, 	} 	p, _ := summarisationFixturePoller(t, ms)
internal/poller/dailysummary.go Modified +97 / -31
diff --git a/internal/poller/dailysummary.go b/internal/poller/dailysummary.goindex 23a3467..ab360a4 100644--- a/internal/poller/dailysummary.go+++ b/internal/poller/dailysummary.go@@ -6,9 +6,9 @@ import ( 	"sync" 	"time" -	"github.com/ArjenSchwarz/flux/internal/config" 	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" )  // pollDailySummary runs the daily-derived-stats summarisation pass for@@ -52,26 +52,51 @@ func (p *Poller) runSummarisationPass(ctx context.Context, date string) string { 		return PassResultSkippedNoRow 	} -	// Two orthogonal sentinels gate two independent compute blocks-	// (peak-from-readings Decision 3). Skip the whole pass only when BOTH are+	// Three orthogonal sentinels gate three independent compute blocks+	// (peak-from-readings Decision 3). Skip the whole pass only when ALL are 	// set; otherwise compute whichever group is still missing. A row with 	// derived stats but no peak (e.g. pre-feature row picked up after deploy) 	// gets only peak written. 	needDerived := item.DerivedStatsComputedAt == "" 	needPeak := item.PeakComputedAt == ""-	if !needDerived && !needPeak {-		// AC 1.10 / daily-derived-stats Decision 8 — both sentinels present+	needBands := item.BandsComputedAt == ""+	if !needDerived && !needPeak && !needBands {+		// AC 1.10 / daily-derived-stats Decision 8 — every sentinel present 		// means a prior pass computed everything. 		return PassResultSkippedAlreadyDone 	} -	// 2. Off-peak window resolution (AC 1.6 / 1.14). Needed by both blocks.-	offpeakStart := config.FormatHHMM(p.cfg.OffpeakStart)-	offpeakEnd := config.FormatHHMM(p.cfg.OffpeakEnd)-	startMin, endMin, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)-	if !ok {-		slog.Warn("summary skipped: off-peak window unresolved", "date", date)-		return PassResultSkippedSSMUnresolved+	// 2. Plan resolution (AC 4.1). The plan pricing the date is the source of+	// truth for the free window; the three outcomes below are gated+	// separately because they mean different things (Q33).+	plans, err := p.plans.Plans(ctx)+	if err != nil {+		// AC 4.6: an unreadable pricing table is transient, never "no plan".+		// Setting sentinels here would make the day terminal on the strength+		// of an infra blip, so nothing is written and the next tick retries.+		slog.Error("summary plan read failed", "date", date, "error", err)+		return PassResultError+	}+	datePlan, hasPlan := plan.PlanFor(plans, date)+	// hhmm bounds for the derivedstats helpers. Empty strings are how those+	// helpers already express "no off-peak window", and they degrade to their+	// window-free layouts rather than defaulting to a window that isn't there.+	var offpeakStart, offpeakEnd string+	if startMin, endMin, ok := datePlan.FreeWindowMinutes(); hasPlan && ok {+		offpeakStart = plan.FormatBandTime(startMin)+		offpeakEnd = plan.FormatBandTime(endMin)+	}++	// Nothing left to compute for this date. Only reachable with no plan: the+	// band and peak groups both need one, and their sentinels are deliberately+	// left unset so a later backfill can still capture the split — which means+	// without this gate the pass would re-query a full day of readings every+	// hour, forever, to compute nothing and write nothing. Returning here keeps+	// the day repairable while making an unpriced date visible in the metric+	// rather than indistinguishable from useful work.+	if !needDerived && (!hasPlan || (!needPeak && !needBands)) {+		slog.Info("summary skipped: no plan prices this date", "date", date)+		return PassResultSkippedNoPlan 	}  	// 3. Fetch the day's readings.@@ -94,35 +119,55 @@ func (p *Poller) runSummarisationPass(ctx context.Context, date string) string { 	// 4a. derivedStats block — gated on its own sentinel. Pass `today=date` so 	// the today-gate cannot fire on a completed date (AC 1.2 + the "today" 	// parameter contract on derivedstats.Blocks).+	//+	// With no plan at all only socLow runs: the block layout and the peak+	// periods both partition the day around the free window, and guessing one+	// would bake a wrong layout into a sentinel-gated field. socLow needs no+	// window, and the design's rule is that a semantic absence never returns+	// before window-independent work has run. 	if needDerived { 		socLow, socLowTS, socFound := derivedstats.MinSOC(readings)-		derived.DailyUsage = dynamo.DailyUsageToAttr(derivedstats.Blocks(readings, offpeakStart, offpeakEnd, date, date, now))-		derived.PeakPeriods = dynamo.PeakPeriodsToAttr(derivedstats.PeakPeriods(readings, offpeakStart, offpeakEnd))-		derived.DerivedStatsComputedAt = now.UTC().Format(time.RFC3339) 		if socFound { 			derived.SocLow = &dynamo.SocLowAttr{ 				Soc:       socLow, 				Timestamp: time.Unix(socLowTS, 0).UTC().Format(time.RFC3339), 			} 		}+		if hasPlan {+			derived.DailyUsage = dynamo.DailyUsageToAttr(derivedstats.Blocks(readings, offpeakStart, offpeakEnd, date, date, now))+			derived.PeakPeriods = dynamo.PeakPeriodsToAttr(derivedstats.PeakPeriods(readings, offpeakStart, offpeakEnd))+		}+		derived.DerivedStatsComputedAt = now.UTC().Format(time.RFC3339) 	} -	// 4b. Peak grid import block — gated on its own sentinel. The off-peak-	// window bounds the two peak sub-windows. Boundaries are derived from the-	// DST-correct dayStart so 23h/25h Sydney days integrate correctly. When-	// the usability gate fails for either sub-window the field is left absent-	// (PeakGridImportKwh stays nil), but the sentinel is still set so the row-	// is not re-attempted every hour.-	if needPeak {-		offpeakStartUnix := dayStart.Add(time.Duration(startMin) * time.Minute).Unix()-		offpeakEndUnix := dayStart.Add(time.Duration(endMin) * time.Minute).Unix()-		kwh, _, _, peakOK := derivedstats.IntegratePeakGridImportKwh(-			readings, dayStart.Unix(), offpeakStartUnix, offpeakEndUnix, dayEnd.Unix())-		if peakOK {-			rounded := derivedstats.RoundEnergy(kwh)-			derived.PeakGridImportKwh = &rounded+	// 4b. Rated-band block, shared by the peak and band groups. Both describe+	// the same physical quantity — grid import outside the free window — so+	// they come from one integration and cannot disagree (Data Consistency).+	// A plan without a free band leaves the whole day rated, which is exactly+	// what "whole-day-rated mode" means for both values.+	//+	// Without a plan neither is defined: "peak" means "outside the free+	// window", and no plan means no answer to what that window is. Both+	// sentinels stay unset so a backfill can still capture the split once a+	// plan exists — terminal only once the readings TTL prunes the day.+	if hasPlan && (needPeak || needBands) {+		bands, totalKwh, bandsOK := dynamo.IntegrateRatedBands(readings, datePlan, dayStart, p.cfg.Location)+		if needPeak {+			// The usability gate is shared too: a split missing any segment+			// cannot produce a trustworthy total either. The field stays+			// absent, but the sentinel is set so the row is not re-attempted+			// every hour.+			if bandsOK {+				derived.PeakGridImportKwh = &totalKwh+			}+			derived.PeakComputedAt = now.UTC().Format(time.RFC3339)+		}+		if needBands {+			if bandsOK {+				derived.BandImports = bands+			}+			derived.BandsComputedAt = now.UTC().Format(time.RFC3339) 		}-		derived.PeakComputedAt = now.UTC().Format(time.RFC3339) 	}  	// 5. Write — UpdateDailyEnergyDerived writes each group only when its@@ -131,10 +176,31 @@ func (p *Poller) runSummarisationPass(ctx context.Context, date string) string { 		slog.Error("summary write failed", "date", date, "error", err) 		return PassResultError 	}-	slog.Info("summary written", "date", date, "wroteDerived", needDerived, "wrotePeak", needPeak)+	slog.Info("summary written", "date", date,+		"plan", planLabel(datePlan, hasPlan), "window", windowLabel(offpeakStart, offpeakEnd),+		"wroteDerived", derived.DerivedStatsComputedAt != "",+		"wrotePeak", derived.PeakComputedAt != "",+		"wroteBands", derived.BandsComputedAt != "") 	return PassResultSuccess } +// planLabel renders the plan pricing the date for the pass's log line.+func planLabel(p plan.Plan, hasPlan bool) string {+	if !hasPlan {+		return "none"+	}+	return p.ID+}++// windowLabel renders the resolved free window, or "none" when the day's plan+// has no free band.+func windowLabel(start, end string) string {+	if start == "" || end == "" {+		return "none"+	}+	return start + "-" + end+}+ // summaryToDerivedReadings converts the storage-level []dynamo.ReadingItem // to the leaf-package []derivedstats.Reading. Per Decision 9 this conversion // is duplicated at each call site (api/day.go, api/history.go, here) rather
internal/poller/metrics_test.go Modified +0 / -1
diff --git a/internal/poller/metrics_test.go b/internal/poller/metrics_test.goindex 27599d7..36af048 100644--- a/internal/poller/metrics_test.go+++ b/internal/poller/metrics_test.go@@ -31,7 +31,6 @@ func TestMetrics_RecordSummarisationPass_EmitsCorrectShape(t *testing.T) { 		PassResultSuccess, 		PassResultSkippedNoReadings, 		PassResultSkippedNoRow,-		PassResultSkippedSSMUnresolved, 		PassResultSkippedAlreadyDone, 		PassResultError, 	}
internal/poller/metrics.go Modified +17 / -6
diff --git a/internal/poller/metrics.go b/internal/poller/metrics.goindex 18575eb..080eb25 100644--- a/internal/poller/metrics.go+++ b/internal/poller/metrics.go@@ -18,13 +18,24 @@ const ( // SummarisationPassResult dimension values emitted by the daily-derived-stats // summarisation pass. These map 1:1 to the AC 1.11 dimensions; a CloudWatch // alarm on absence of `PassResultSuccess` for >24h flags a stuck pass.+// The former "skipped-ssm-unresolved" dimension is gone: the window comes+// from the plan now, and the outcomes that used to collapse into it are+// distinguished instead — a plan read failure reports `error` so it is+// retried, while a day with no free band or no plan runs its+// window-independent blocks and reports `success` (Q33).+//+// `skipped-no-plan` is the steady state for a date no plan prices once its+// window-independent stats have been written: the peak and band sentinels stay+// unset so a backfill can still capture the split, and the pass stops re-reading+// the day's readings every hour to compute nothing. A rising count means dates+// are going unpriced. const (-	PassResultSuccess              = "success"-	PassResultError                = "error"-	PassResultSkippedNoRow         = "skipped-no-row"-	PassResultSkippedAlreadyDone   = "skipped-already-populated"-	PassResultSkippedSSMUnresolved = "skipped-ssm-unresolved"-	PassResultSkippedNoReadings    = "skipped-no-readings"+	PassResultSuccess            = "success"+	PassResultError              = "error"+	PassResultSkippedNoRow       = "skipped-no-row"+	PassResultSkippedAlreadyDone = "skipped-already-populated"+	PassResultSkippedNoReadings  = "skipped-no-readings"+	PassResultSkippedNoPlan      = "skipped-no-plan" )  // MetricsRecorder is the small surface the poller uses for emitting custom
internal/poller/offpeak_plan_test.go Added +410 / -0
diff --git a/internal/poller/offpeak_plan_test.go b/internal/poller/offpeak_plan_test.gonew file mode 100644index 0000000..b5cfd7d--- /dev/null+++ b/internal/poller/offpeak_plan_test.go@@ -0,0 +1,410 @@+package poller++import (+	"context"+	"errors"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/alphaess"+	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// testPlanItem builds a pricing row whose free band is the given window and+// whose remainder carries a single flat rate — the shape every migrated+// legacy plan has.+func testPlanItem(id, startDate, endDate, freeStart, freeEnd string) dynamo.PricingItem {+	savings := 0.35+	item := dynamo.PricingItem{+		PricingID:            id,+		StartDate:            startDate,+		DefaultRate:          0.35,+		Windows:              []dynamo.PricingWindow{{Start: freeStart, End: freeEnd, Free: true}},+		FeedInRate:           0.05,+		SavingsReferenceRate: &savings,+	}+	if endDate != "" {+		item.EndDate = &endDate+	}+	return item+}++// planSourceWith returns a PlanSource serving exactly these rows.+func planSourceWith(items ...dynamo.PricingItem) *PlanSource {+	return testPlanSource(&mockPlanLister{responses: []planListerResponse{{items: items}}})+}++// openEndedPlanLister serves one open-ended plan whose free band is the+// given window — the shape every pre-feature day was priced under.+func openEndedPlanLister(freeStart, freeEnd string) PlanLister {+	return &mockPlanLister{responses: []planListerResponse{+		{items: []dynamo.PricingItem{testPlanItem("plan", "2000-01-01", "", freeStart, freeEnd)}},+	}}+}++// failingPlanLister is permanently unreachable.+func failingPlanLister() PlanLister {+	return &mockPlanLister{responses: []planListerResponse{{err: errors.New("pricing table unreachable")}}}+}++// failingPlanSource returns a PlanSource whose store is permanently+// unreachable and which has never cached a good result.+func failingPlanSource() *PlanSource {+	return testPlanSource(failingPlanLister())+}++func testScheduler(client APIClient, store dynamo.Store, plans *PlanSource) *OffpeakScheduler {+	return &OffpeakScheduler{+		client: client, store: store, cfg: testOffpeakCfg(), plans: plans,+		retryDelay: time.Millisecond, now: time.Now,+	}+}++// --- Window resolution from the plan covering the date ---++func TestResolveWindow_FromPlanCoveringDate(t *testing.T) {+	cfg := testOffpeakCfg()+	o := testScheduler(&mockClient{}, &mockStore{}, planSourceWith(+		testPlanItem("a", "2026-01-01", "", "10:00", "15:00"),+	))+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)++	win, ok, err := o.resolveWindow(t.Context(), day, "2026-04-13")++	require.NoError(t, err)+	require.True(t, ok)+	assert.Equal(t, "10:00", win.StartHHMM)+	assert.Equal(t, "15:00", win.EndHHMM)+	assert.Equal(t, time.Date(2026, 4, 13, 10, 0, 0, 0, cfg.Location), win.Start)+	assert.Equal(t, time.Date(2026, 4, 13, 15, 0, 0, 0, cfg.Location), win.End)+}++// AC 2.2 / AC 4.2: the switch day belongs to the successor, so the window+// changes at midnight without any manual reconfiguration.+func TestResolveWindow_SwitchDayUsesSuccessorWindow(t *testing.T) {+	cfg := testOffpeakCfg()+	src := planSourceWith(+		testPlanItem("old", "2026-01-01", "2026-08-01", "11:00", "14:00"),+		testPlanItem("new", "2026-08-01", "", "10:00", "15:00"),+	)+	o := testScheduler(&mockClient{}, &mockStore{}, src)++	eve := time.Date(2026, 7, 31, 0, 0, 0, 0, cfg.Location)+	win, ok, err := o.resolveWindow(t.Context(), eve, "2026-07-31")+	require.NoError(t, err)+	require.True(t, ok)+	assert.Equal(t, "11:00", win.StartHHMM, "switch eve is still priced by the predecessor")++	switchDay := time.Date(2026, 8, 1, 0, 0, 0, 0, cfg.Location)+	win, ok, err = o.resolveWindow(t.Context(), switchDay, "2026-08-01")+	require.NoError(t, err)+	require.True(t, ok)+	assert.Equal(t, "10:00", win.StartHHMM, "the switch day belongs to the successor")+	assert.Equal(t, "15:00", win.EndHHMM)+}++func TestResolveWindow_PlanWithoutFreeBand(t *testing.T) {+	cfg := testOffpeakCfg()+	rated := dynamo.PricingItem{+		PricingID: "rated", StartDate: "2026-01-01", DefaultRate: 0.35, FeedInRate: 0.05,+	}+	o := testScheduler(&mockClient{}, &mockStore{}, planSourceWith(rated))+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)++	_, ok, err := o.resolveWindow(t.Context(), day, "2026-04-13")++	require.NoError(t, err)+	assert.False(t, ok, "no free band → no window to process")+}++func TestResolveWindow_NoPlanCoversDate(t *testing.T) {+	cfg := testOffpeakCfg()+	o := testScheduler(&mockClient{}, &mockStore{}, planSourceWith(+		testPlanItem("a", "2026-05-01", "", "10:00", "15:00"),+	))+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)++	_, ok, err := o.resolveWindow(t.Context(), day, "2026-04-13")++	require.NoError(t, err)+	assert.False(t, ok)+}++// AC 4.6: an unreadable pricing table is transient, not "no plan". It must+// surface as an error the caller retries, never as an absent window.+func TestResolveWindow_PlanReadFailureIsAnError(t *testing.T) {+	cfg := testOffpeakCfg()+	o := testScheduler(&mockClient{}, &mockStore{}, failingPlanSource())+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)++	_, ok, err := o.resolveWindow(t.Context(), day, "2026-04-13")++	require.Error(t, err)+	assert.False(t, ok)+}++// The window can only be resolved on a DST day by wall clock; adding elapsed+// minutes to midnight would be an hour off on the 23-hour day.+func TestResolveWindow_DSTDayUsesWallClock(t *testing.T) {+	sydney, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)++	o := testScheduler(&mockClient{}, &mockStore{}, planSourceWith(+		testPlanItem("a", "2026-01-01", "", "10:00", "15:00"),+	))+	o.cfg.Location = sydney++	// 2026-10-04 is the Sydney DST start (02:00 → 03:00), a 23-hour day.+	day := time.Date(2026, 10, 4, 0, 0, 0, 0, sydney)+	win, ok, err := o.resolveWindow(t.Context(), day, "2026-10-04")++	require.NoError(t, err)+	require.True(t, ok)+	assert.Equal(t, 10, win.Start.Hour(), "window start stays at local 10:00")+	assert.Equal(t, 15, win.End.Hour(), "window end stays at local 15:00")+}++// --- Position relative to the resolved window ---++func TestPositionFor(t *testing.T) {+	cfg := testOffpeakCfg()+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)+	win := offpeakWindow{+		Start:     time.Date(2026, 4, 13, 1, 0, 0, 0, cfg.Location),+		End:       time.Date(2026, 4, 13, 6, 0, 0, 0, cfg.Location),+		StartHHMM: "01:00", EndHHMM: "06:00",+	}++	tests := map[string]struct {+		now  time.Time+		want windowPosition+	}{+		"before window":    {now: day.Add(30 * time.Minute), want: positionBefore},+		"exactly at start": {now: win.Start, want: positionDuring},+		"during window":    {now: day.Add(3 * time.Hour), want: positionDuring},+		"exactly at end":   {now: win.End, want: positionAfter},+		"after window":     {now: day.Add(12 * time.Hour), want: positionAfter},+	}++	for name, tc := range tests {+		t.Run(name, func(t *testing.T) {+			assert.Equal(t, tc.want, positionFor(tc.now, win))+		})+	}+}++// --- Window geometry snapshotted onto the row ---++// testWindow is the 01:00–06:00 window the offpeak fixtures use, as an+// already-resolved offpeakWindow for the given day.+func testWindow(day time.Time) offpeakWindow {+	return offpeakWindow{+		Start:     time.Date(day.Year(), day.Month(), day.Day(), 1, 0, 0, 0, day.Location()),+		End:       time.Date(day.Year(), day.Month(), day.Day(), 6, 0, 0, 0, day.Location()),+		StartHHMM: "01:00",+		EndHHMM:   "06:00",+	}+}++func TestHandleEnd_SnapshotsWindowGeometry(t *testing.T) {+	cfg := testOffpeakCfg()+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)+	win := offpeakWindow{+		Start:     time.Date(2026, 4, 13, 10, 0, 0, 0, cfg.Location),+		End:       time.Date(2026, 4, 13, 15, 0, 0, 0, cfg.Location),+		StartHHMM: "10:00", EndHHMM: "15:00",+	}+	readings := fixtureReadings(win.Start, win.End, 0, 1000, -1000)++	var captured dynamo.OffpeakItem+	ms := &mockStore{+		queryReadingsConsistentFunc: func(_ context.Context, _ string, from, to int64) ([]dynamo.ReadingItem, error) {+			assert.Equal(t, win.Start.Unix(), from, "integration must run over the plan's window")+			assert.Equal(t, win.End.Unix(), to)+			return readings, nil+		},+		writeOffpeakIfPendingOrAbsentFunc: func(_ context.Context, item dynamo.OffpeakItem) error {+			captured = item+			return nil+		},+	}+	mc := &mockClient{+		oneDateEnergy: &alphaess.EnergyData{EInput: 8.0},+		lastPowerData: &alphaess.PowerData{Soc: 90.0},+	}+	o := testScheduler(mc, ms, planSourceWith())+	o.now = func() time.Time { return win.End.Add(time.Second) }+	o.startSnapshot = &alphaess.EnergyData{EInput: 2.0}+	o.socStart = 20.0+	_ = day++	require.NoError(t, o.handleEnd(t.Context(), "2026-04-13", nil, win))++	assert.Equal(t, "10:00", captured.WindowStart)+	assert.Equal(t, "15:00", captured.WindowEnd)+}++// --- Q36: readings-only finalisation ---++// A plan-read failure at window start means handleStart never ran, so there+// is neither in-memory state nor a pending row. The integration never needed+// either (offpeak-from-readings Decision 2), so a plan load that succeeds+// later in the day must still finalise the window.+func TestHandleEnd_FinalisesFromReadingsWithoutStartSnapshot(t *testing.T) {+	cfg := testOffpeakCfg()+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)+	win := testWindow(day)+	readings := fixtureReadings(win.Start, win.End, 0, 2000, -1500)++	var captured dynamo.OffpeakItem+	writes := 0+	ms := &mockStore{+		queryReadingsConsistentFunc: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			return readings, nil+		},+		writeOffpeakIfPendingOrAbsentFunc: func(_ context.Context, item dynamo.OffpeakItem) error {+			writes+++			captured = item+			return nil+		},+	}+	mc := &mockClient{+		oneDateEnergy: &alphaess.EnergyData{EInput: 12.0},+		lastPowerData: &alphaess.PowerData{Soc: 95.0},+	}+	o := testScheduler(mc, ms, planSourceWith())+	o.now = func() time.Time { return win.End.Add(time.Second) }++	require.NoError(t, o.handleEnd(t.Context(), "2026-04-13", nil, win))++	assert.Equal(t, 1, writes, "no pending row and no snapshot must not forfeit the day")+	assert.Equal(t, dynamo.OffpeakStatusComplete, captured.Status)+	assert.InDelta(t, 10.0, captured.GridUsageKwh, 0.05)+	assert.Zero(t, captured.StartEInput, "no start snapshot to record")+	assert.Zero(t, captured.BatteryDeltaPercent, "SoC delta is unknown without a start snapshot")+}++func TestRecoverAfterWindow_NoRow_FinalisesFromReadings(t *testing.T) {+	cfg := testOffpeakCfg()+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)+	win := testWindow(day)+	readings := fixtureReadings(win.Start, win.End, 0, 2000, -1500)++	writes := 0+	ms := &mockStore{+		getOffpeakResult: nil,+		queryReadingsConsistentFunc: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			return readings, nil+		},+		writeOffpeakIfPendingOrAbsentFunc: func(_ context.Context, _ dynamo.OffpeakItem) error {+			writes+++			return nil+		},+	}+	mc := &mockClient{+		oneDateEnergy: &alphaess.EnergyData{EInput: 12.0},+		lastPowerData: &alphaess.PowerData{Soc: 95.0},+	}+	o := testScheduler(mc, ms, planSourceWith())+	o.now = func() time.Time { return win.End.Add(30 * time.Minute) }++	o.recoverAfterWindow(t.Context(), "2026-04-13", win)++	assert.Equal(t, 1, writes, "an absent row is repaired from readings, not skipped (Q36)")+}++func TestRecoverAfterWindow_CompleteRow_StillSkips(t *testing.T) {+	cfg := testOffpeakCfg()+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)+	win := testWindow(day)++	writes := 0+	ms := &mockStore{+		getOffpeakResult: &dynamo.OffpeakItem{+			SysSn: "TEST123", Date: "2026-04-13", Status: dynamo.OffpeakStatusComplete,+		},+		writeOffpeakIfPendingOrAbsentFunc: func(_ context.Context, _ dynamo.OffpeakItem) error {+			writes+++			return nil+		},+	}+	o := testScheduler(&mockClient{}, ms, planSourceWith())+	o.now = func() time.Time { return win.End.Add(30 * time.Minute) }++	o.recoverAfterWindow(t.Context(), "2026-04-13", win)++	assert.Equal(t, 0, writes, "an already-finalised row must not be re-written")+}++// --- Day cycle ---++// A mid-window restart with no pending row still finalises: the pending row+// carries diagnostics only, so its absence is not a reason to drop the day.+func TestRunWindow_MidWindowWithoutPendingRow_StillFinalises(t *testing.T) {+	cfg := testOffpeakCfg()+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)+	win := testWindow(day)+	readings := fixtureReadings(win.Start, win.End, 0, 2000, -1500)+	readings = append(readings, dynamo.ReadingItem{SysSn: "TEST123", Timestamp: win.End.Unix()})++	writes := 0+	ms := &mockStore{+		getOffpeakResult: nil,+		queryReadingsConsistentFunc: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			return readings, nil+		},+		writeOffpeakIfPendingOrAbsentFunc: func(_ context.Context, _ dynamo.OffpeakItem) error {+			writes+++			return nil+		},+	}+	mc := &mockClient{+		oneDateEnergy: &alphaess.EnergyData{EInput: 12.0},+		lastPowerData: &alphaess.PowerData{Soc: 95.0},+	}+	o := testScheduler(mc, ms, planSourceWith())+	o.now = func() time.Time { return win.Start.Add(2 * time.Hour) }++	require.True(t, o.runWindow(t.Context(), t.Context(), "2026-04-13", win))+	assert.Equal(t, 1, writes)+}++// A failed start snapshot no longer skips the day: the window still closes+// from readings (Q36).+func TestRunWindow_StartSnapshotFailure_StillFinalisesAtEnd(t *testing.T) {+	cfg := testOffpeakCfg()+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)+	win := testWindow(day)+	readings := fixtureReadings(win.Start, win.End, 0, 2000, -1500)+	readings = append(readings, dynamo.ReadingItem{SysSn: "TEST123", Timestamp: win.End.Unix()})++	writes := 0+	ms := &mockStore{+		queryReadingsConsistentFunc: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {+			return readings, nil+		},+		writeOffpeakIfPendingOrAbsentFunc: func(_ context.Context, _ dynamo.OffpeakItem) error {+			writes+++			return nil+		},+	}+	// First snapshot call (handleStart) fails, later ones succeed.+	calls := 0+	mc := &retryMockClient{+		mockClient: &mockClient{lastPowerData: &alphaess.PowerData{Soc: 95.0}},+		energyFunc: func() (*alphaess.EnergyData, error) {+			calls+++			if calls <= snapshotRetries {+				return nil, errors.New("alphaess unavailable")+			}+			return &alphaess.EnergyData{EInput: 12.0}, nil+		},+	}+	o := testScheduler(mc, ms, planSourceWith())+	o.now = func() time.Time { return day }++	require.True(t, o.runWindow(t.Context(), t.Context(), "2026-04-13", win))+	assert.Equal(t, 1, writes, "window end must still finalise from readings")+}
internal/poller/offpeak_test.go Modified +43 / -101
diff --git a/internal/poller/offpeak_test.go b/internal/poller/offpeak_test.goindex 8eae461..847178c 100644--- a/internal/poller/offpeak_test.go+++ b/internal/poller/offpeak_test.go@@ -13,51 +13,19 @@ import ( 	"github.com/stretchr/testify/require" ) +// testOffpeakWindowStart / testOffpeakWindowEnd is the 01:00–06:00 free+// window the fixtures in this file are built around. It comes from the day's+// plan in production; the tests pin it here so they don't have to build a+// plan just to exercise snapshot capture and integration.+const (+	testOffpeakWindowStart = 1 * time.Hour+	testOffpeakWindowEnd   = 6 * time.Hour+)+ func testOffpeakCfg() *config.Config { 	return &config.Config{-		Serial:       "TEST123",-		Location:     time.FixedZone("AEST", 10*60*60),-		OffpeakStart: 1 * time.Hour, // 01:00-		OffpeakEnd:   6 * time.Hour, // 06:00-	}-}--// --- Tests for time position detection -----func TestTimePosition(t *testing.T) {-	cfg := testOffpeakCfg()--	tests := map[string]struct {-		now  time.Time-		want windowPosition-	}{-		"before window": {-			now:  time.Date(2026, 4, 13, 0, 30, 0, 0, cfg.Location),-			want: positionBefore,-		},-		"exactly at start": {-			now:  time.Date(2026, 4, 13, 1, 0, 0, 0, cfg.Location),-			want: positionDuring,-		},-		"during window": {-			now:  time.Date(2026, 4, 13, 3, 0, 0, 0, cfg.Location),-			want: positionDuring,-		},-		"exactly at end": {-			now:  time.Date(2026, 4, 13, 6, 0, 0, 0, cfg.Location),-			want: positionAfter,-		},-		"after window": {-			now:  time.Date(2026, 4, 13, 12, 0, 0, 0, cfg.Location),-			want: positionAfter,-		},-	}--	for name, tc := range tests {-		t.Run(name, func(t *testing.T) {-			got := timePosition(tc.now, cfg.OffpeakStart, cfg.OffpeakEnd)-			assert.Equal(t, tc.want, got)-		})+		Serial:   "TEST123",+		Location: time.FixedZone("AEST", 10*60*60), 	} } @@ -131,6 +99,7 @@ func TestOffpeak_StartSucceeds_EndFails_DeletesPending(t *testing.T) { 		lastPowerData: &alphaess.PowerData{Soc: 50.0}, 	} 	cfg := testOffpeakCfg()+	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location) 	o := &OffpeakScheduler{client: mc, store: ms, cfg: cfg, retryDelay: 1 * time.Millisecond, now: time.Now}  	// Simulate start capture.@@ -139,7 +108,7 @@ func TestOffpeak_StartSucceeds_EndFails_DeletesPending(t *testing.T) {  	// Now make API fail for end capture. 	mc.oneDateEnergyErr = errors.New("end snapshot fail")-	err = o.handleEnd(context.Background(), "2026-04-13", nil)+	err = o.handleEnd(context.Background(), "2026-04-13", nil, testWindow(day)) 	require.Error(t, err)  	assert.True(t, logContains(buf, "end snapshot fail") || logContains(buf, "3 attempts"))@@ -221,8 +190,8 @@ func TestOffpeak_MidWindowRecovery_StoreError(t *testing.T) { func TestPositionAfterRecovery_PendingRow_RunsHandleEndImmediately(t *testing.T) { 	cfg := testOffpeakCfg() 	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)-	windowStart := day.Add(cfg.OffpeakStart)-	windowEnd := day.Add(cfg.OffpeakEnd)+	windowStart := day.Add(testOffpeakWindowStart)+	windowEnd := day.Add(testOffpeakWindowEnd) 	readings := fixtureReadings(windowStart, windowEnd, 0, 2000, -1500)  	pending := &dynamo.OffpeakItem{@@ -250,46 +219,20 @@ func TestPositionAfterRecovery_PendingRow_RunsHandleEndImmediately(t *testing.T) 		client: mc, store: ms, cfg: cfg, retryDelay: 1 * time.Millisecond, 		// Clock past offpeak-end so the recovery path's "skip wait" branch 		// actually skips the wait — the boundary is in the past.-		now: func() time.Time { return day.Add(cfg.OffpeakEnd + 30*time.Minute) },+		now: func() time.Time { return day.Add(testOffpeakWindowEnd + 30*time.Minute) }, 	} -	o.recoverAfterWindow(context.Background(), "2026-04-13")+	o.recoverAfterWindow(context.Background(), "2026-04-13", testWindow(day)) 	assert.Equal(t, 1, writes, "handleEnd-style integration must finalise the row") 	assert.Equal(t, dynamo.OffpeakStatusComplete, captured.Status) 	assert.InDelta(t, 10.0, captured.GridUsageKwh, 0.05) 	assert.Greater(t, captured.IntegrationSampleCount, 0) } -func TestPositionAfterRecovery_NoRow_LogsAndSkips(t *testing.T) {-	buf, restore := captureLog()-	defer restore()--	cfg := testOffpeakCfg()-	ms := &mockStore{getOffpeakResult: nil}-	o := &OffpeakScheduler{store: ms, cfg: cfg, now: time.Now}--	o.recoverAfterWindow(context.Background(), "2026-04-13")-	assert.True(t, logContains(buf, "past window") || logContains(buf, "no pending"),-		"absent row → log and skip")-}--func TestPositionAfterRecovery_CompleteRow_LogsAndSkips(t *testing.T) {-	cfg := testOffpeakCfg()-	completeWrites := 0-	ms := &mockStore{-		getOffpeakResult: &dynamo.OffpeakItem{-			SysSn: "TEST123", Date: "2026-04-13", Status: dynamo.OffpeakStatusComplete,-		},-		writeOffpeakIfPendingOrAbsentFunc: func(_ context.Context, _ dynamo.OffpeakItem) error {-			completeWrites++-			return nil-		},-	}-	o := &OffpeakScheduler{store: ms, cfg: cfg, now: time.Now}--	o.recoverAfterWindow(context.Background(), "2026-04-13")-	assert.Equal(t, 0, completeWrites, "already-complete row must not be re-written")-}+// The absent-row and complete-row recovery paths moved to+// offpeak_plan_test.go: under Q36 an absent row is repaired from readings+// rather than skipped, so the assertions changed shape along with the+// behaviour.  // --- Tests for DST-safe wall-clock scheduling --- @@ -297,22 +240,21 @@ func TestWallClockTime_DST(t *testing.T) { 	sydney, err := time.LoadLocation("Australia/Sydney") 	require.NoError(t, err) -	cfg := &config.Config{-		Serial:       "TEST123",-		Location:     sydney,-		OffpeakStart: 1 * time.Hour,-		OffpeakEnd:   6 * time.Hour,+	windowOn := func(day time.Time) offpeakWindow {+		return offpeakWindow{+			Start:     wallClockTime(day, sydney, testOffpeakWindowStart),+			End:       wallClockTime(day, sydney, testOffpeakWindowEnd),+			StartHHMM: "01:00", EndHHMM: "06:00",+		} 	}  	// During AEDT (UTC+11), 01:00 local = 14:00 UTC previous day. 	aedt := time.Date(2026, 1, 15, 1, 0, 0, 0, sydney)-	pos := timePosition(aedt, cfg.OffpeakStart, cfg.OffpeakEnd)-	assert.Equal(t, positionDuring, pos)+	assert.Equal(t, positionDuring, positionFor(aedt, windowOn(aedt)))  	// During AEST (UTC+10), 01:00 local = 15:00 UTC previous day. 	aest := time.Date(2026, 7, 15, 1, 0, 0, 0, sydney)-	pos = timePosition(aest, cfg.OffpeakStart, cfg.OffpeakEnd)-	assert.Equal(t, positionDuring, pos)+	assert.Equal(t, positionDuring, positionFor(aest, windowOn(aest))) }  // --- retryMockClient wraps mockClient with custom energy function ---@@ -416,8 +358,8 @@ func TestHandleEnd_CallsLogOffpeakDrift(t *testing.T) {  	cfg := testOffpeakCfg() 	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)-	windowStart := day.Add(cfg.OffpeakStart)-	windowEnd := day.Add(cfg.OffpeakEnd)+	windowStart := day.Add(testOffpeakWindowStart)+	windowEnd := day.Add(testOffpeakWindowEnd) 	readings := fixtureReadings(windowStart, windowEnd, 0, 1000, 0) 	readings = append(readings, dynamo.ReadingItem{Timestamp: windowEnd.Unix() + 3}) @@ -444,7 +386,7 @@ func TestHandleEnd_CallsLogOffpeakDrift(t *testing.T) { 	o.startSnapshot = &alphaess.EnergyData{EInput: 1.0} 	o.socStart = 20.0 -	require.NoError(t, o.handleEnd(context.Background(), "2026-04-13", nil))+	require.NoError(t, o.handleEnd(context.Background(), "2026-04-13", nil, testWindow(day))) 	assert.Equal(t, 1, writes) 	assert.True(t, logContains(buf, "offpeak drift"), "handleEnd must emit a drift line") 	assert.True(t, driftSeenBeforeWrite, "drift log must be emitted before the conditional write fires")@@ -470,8 +412,8 @@ func TestHandleEnd_HappyPath_IntegratesAndWrites(t *testing.T) { 	cfg := testOffpeakCfg() 	// Window: 01:00 → 06:00 on 2026-04-13, AEST. 	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)-	windowStart := day.Add(cfg.OffpeakStart)-	windowEnd := day.Add(cfg.OffpeakEnd)+	windowStart := day.Add(testOffpeakWindowStart)+	windowEnd := day.Add(testOffpeakWindowEnd) 	// Charge at 2000 W on the grid (heavy off-peak charging), 1500 W to the 	// battery, no solar (off-peak window is overnight). 	readings := fixtureReadings(windowStart, windowEnd, 0, 2000, -1500)@@ -515,7 +457,7 @@ func TestHandleEnd_HappyPath_IntegratesAndWrites(t *testing.T) { 	} 	o.socStart = 20.0 -	err := o.handleEnd(context.Background(), "2026-04-13", nil)+	err := o.handleEnd(context.Background(), "2026-04-13", nil, testWindow(day)) 	require.NoError(t, err) 	assert.Equal(t, 1, writeCalled, "WriteOffpeakIfPendingOrAbsent must be called once") 	assert.Equal(t, dynamo.OffpeakStatusComplete, captured.Status)@@ -539,8 +481,8 @@ func TestHandleEnd_HappyPath_IntegratesAndWrites(t *testing.T) { func TestHandleEnd_BoundaryWaitTimeout_StillWritesRow(t *testing.T) { 	cfg := testOffpeakCfg() 	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)-	windowStart := day.Add(cfg.OffpeakStart)-	windowEnd := day.Add(cfg.OffpeakEnd)+	windowStart := day.Add(testOffpeakWindowStart)+	windowEnd := day.Add(testOffpeakWindowEnd) 	// Build readings strictly before windowEnd — wait-for-boundary will time out. 	readings := fixtureReadings(windowStart, windowEnd.Add(-10*time.Second), 0, 1000, -1000) @@ -577,7 +519,7 @@ func TestHandleEnd_BoundaryWaitTimeout_StillWritesRow(t *testing.T) { 	o.endWaitBudget = 30 * time.Millisecond 	o.endWaitPollInterval = 10 * time.Millisecond -	err := o.handleEnd(context.Background(), "2026-04-13", nil)+	err := o.handleEnd(context.Background(), "2026-04-13", nil, testWindow(day)) 	require.NoError(t, err) 	assert.Equal(t, 1, writeCalled, "row must be written even when the boundary wait times out") 	assert.Equal(t, dynamo.OffpeakStatusComplete, captured.Status)@@ -591,8 +533,8 @@ func TestHandleEnd_ConditionalWriteFails_LogsWarn_NoError(t *testing.T) {  	cfg := testOffpeakCfg() 	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)-	windowStart := day.Add(cfg.OffpeakStart)-	windowEnd := day.Add(cfg.OffpeakEnd)+	windowStart := day.Add(testOffpeakWindowStart)+	windowEnd := day.Add(testOffpeakWindowEnd) 	readings := fixtureReadings(windowStart, windowEnd, 0, 1000, 0) 	readings = append(readings, dynamo.ReadingItem{Timestamp: windowEnd.Unix() + 3}) @@ -614,7 +556,7 @@ func TestHandleEnd_ConditionalWriteFails_LogsWarn_NoError(t *testing.T) { 	} 	o.startSnapshot = &alphaess.EnergyData{} -	err := o.handleEnd(context.Background(), "2026-04-13", nil)+	err := o.handleEnd(context.Background(), "2026-04-13", nil, testWindow(day)) 	require.NoError(t, err, "conditional-failure must be logged, not returned as error") 	assert.True(t, logContains(buf, "conditional"), "warn log should mention the conditional failure") }@@ -622,7 +564,7 @@ func TestHandleEnd_ConditionalWriteFails_LogsWarn_NoError(t *testing.T) { func TestHandleEnd_EmptyReadings_WritesRowWithZeroDeltas(t *testing.T) { 	cfg := testOffpeakCfg() 	day := time.Date(2026, 4, 13, 0, 0, 0, 0, cfg.Location)-	windowEnd := day.Add(cfg.OffpeakEnd)+	windowEnd := day.Add(testOffpeakWindowEnd)  	var captured dynamo.OffpeakItem 	writeCalled := 0@@ -648,7 +590,7 @@ func TestHandleEnd_EmptyReadings_WritesRowWithZeroDeltas(t *testing.T) { 	} 	o.startSnapshot = &alphaess.EnergyData{} -	err := o.handleEnd(context.Background(), "2026-04-13", nil)+	err := o.handleEnd(context.Background(), "2026-04-13", nil, testWindow(day)) 	require.NoError(t, err) 	assert.Equal(t, 1, writeCalled, "row must still be written when readings are empty") 	assert.Equal(t, 0.0, captured.GridUsageKwh)
internal/poller/offpeak.go Modified +231 / -125
diff --git a/internal/poller/offpeak.go b/internal/poller/offpeak.goindex 1ef59fd..65172e6 100644--- a/internal/poller/offpeak.go+++ b/internal/poller/offpeak.go@@ -12,6 +12,7 @@ import ( 	"github.com/ArjenSchwarz/flux/internal/config" 	"github.com/ArjenSchwarz/flux/internal/derivedstats" 	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan" )  const (@@ -28,6 +29,13 @@ const ( 	// the readings table while waiting for an at-or-after-boundary reading. 	// Two seconds keeps the probe lightweight against the live-poll cadence. 	defaultEndWaitPollInterval = 2 * time.Second++	// defaultPlanRetryInterval is how long the scheduler waits before+	// re-attempting a day whose plan could not be read. Retrying within the+	// day (rather than writing it off until midnight) is what lets a late+	// recovery still finalise the window from readings (Q36); the interval is+	// long enough that a sustained outage does not spin.+	defaultPlanRetryInterval = 15 * time.Minute )  // windowPosition represents the poller's position relative to the off-peak window.@@ -39,11 +47,22 @@ const ( 	positionAfter  windowPosition = "after" ) +// offpeakWindow is one day's resolved free window: the absolute local+// boundaries the integration runs over, plus the HH:MM geometry snapshotted+// onto the row so a later plan edit shows up as a mismatch instead of+// silently repricing the day (Q23/Q31).+type offpeakWindow struct {+	Start, End time.Time+	StartHHMM  string+	EndHHMM    string+}+ // OffpeakScheduler manages off-peak window state and snapshot capture. type OffpeakScheduler struct { 	client APIClient 	store  dynamo.Store 	cfg    *config.Config+	plans  *PlanSource  	// In-memory state for current day's off-peak calculation. 	startSnapshot *alphaess.EnergyData@@ -52,6 +71,10 @@ type OffpeakScheduler struct { 	// retryDelay between snapshot attempts (overridable for tests). 	retryDelay time.Duration +	// planRetryInterval is the wait before re-attempting a day whose plan+	// could not be read. Zero means defaultPlanRetryInterval.+	planRetryInterval time.Duration+ 	// endWaitBudget / endWaitPollInterval control the boundary wait in 	// handleEnd (specs/offpeak-from-readings AC 3.1). Zero means "use 	// default" (defaultEndWaitBudget / defaultEndWaitPollInterval). Tests@@ -64,90 +87,140 @@ type OffpeakScheduler struct { }  // NewOffpeakScheduler creates an OffpeakScheduler with the given dependencies.-func NewOffpeakScheduler(client APIClient, store dynamo.Store, cfg *config.Config) *OffpeakScheduler {+func NewOffpeakScheduler(client APIClient, store dynamo.Store, plans *PlanSource, cfg *config.Config) *OffpeakScheduler { 	return &OffpeakScheduler{-		client:     client,-		store:      store,-		cfg:        cfg,-		retryDelay: defaultRetryDelay,-		now:        time.Now,+		client:            client,+		store:             store,+		cfg:               cfg,+		plans:             plans,+		retryDelay:        defaultRetryDelay,+		planRetryInterval: defaultPlanRetryInterval,+		now:               time.Now, 	} } -// Run determines the current position relative to the off-peak window and-// schedules snapshot captures accordingly. Loops daily.+// Run processes one free-window per local day, anchored to midnight (Q27):+// each cycle resolves the window from the plan pricing that day, handles+// whichever boundaries are still ahead, then sleeps to the next midnight.+//+// Anchoring to midnight is what makes plan succession automatic. Plans change+// behaviour only at midnight boundaries (AC 2.2), so refreshing once per day+// is exactly sufficient, and the switch day picks up the successor's window+// with no manual reconfiguration. A same-day edit to today's window is not+// picked up until tomorrow; the backfill CLI is the repair path for that. func (o *OffpeakScheduler) Run(loopCtx, drainCtx context.Context, wg *sync.WaitGroup) { 	defer wg.Done() -	now := o.now().In(o.cfg.Location)-	date := now.Format(dateLayout)-	pos := timePosition(now, o.cfg.OffpeakStart, o.cfg.OffpeakEnd)+	for {+		o.resetState()+		now := o.now().In(o.cfg.Location)+		date := now.Format(dateLayout)++		win, ok, err := o.resolveWindow(drainCtx, now, date)+		switch {+		case err != nil:+			// Plan data is unreadable, which is transient by definition+			// (AC 4.6). Retrying within the day rather than writing it off+			// means a load that recovers before midnight still finalises the+			// window from readings (Q36).+			slog.Error("offpeak: plan resolution failed; retrying later today",+				"date", date, "error", err)+			if !o.waitFor(loopCtx, o.planRetry()) {+				return+			}+			continue+		case !ok:+			// No plan prices the day, or its plan has no free band: there is+			// no window to process, and nothing is written (AC 4.4).+			slog.Info("offpeak: no free window for date, sleeping to next midnight", "date", date)+		default:+			if !o.runWindow(loopCtx, drainCtx, date, win) {+				return+			}+		} -	slog.Debug("offpeak scheduler starting", "position", pos, "date", date)+		if !o.waitUntil(loopCtx, nextLocalMidnight(o.now().In(o.cfg.Location))) {+			return+		}+	}+}++// runWindow processes the boundaries of one day's free window that are still+// ahead of the clock. Returns false when the loop context was cancelled.+func (o *OffpeakScheduler) runWindow(loopCtx, drainCtx context.Context, date string, win offpeakWindow) bool {+	pos := positionFor(o.now().In(o.cfg.Location), win)+	slog.Debug("offpeak day cycle", "date", date, "position", pos,+		"window", win.StartHHMM+"-"+win.EndHHMM)  	switch pos { 	case positionBefore:-		// Wait for start time, then handle start and end. handleStart-		// populates in-memory state, so handleEnd doesn't need the pending row.-		if !o.waitUntil(loopCtx, wallClockTime(now, o.cfg.Location, o.cfg.OffpeakStart)) {-			return+		if !o.waitUntil(loopCtx, win.Start) {+			return false 		} 		if err := o.handleStart(drainCtx, date); err != nil {-			slog.Error("offpeak start failed", "date", date, "error", err)-			// Skip to tomorrow.-			goto nextDay+			// The start snapshot is diagnostics-only since T-1341, so losing+			// it costs forensics, not the day — handleEnd integrates the+			// readings regardless (Q36).+			slog.Error("offpeak start failed; will still finalise from readings at window end",+				"date", date, "error", err) 		}-		if !o.waitUntil(loopCtx, wallClockTime(now, o.cfg.Location, o.cfg.OffpeakEnd)) {-			return+		if !o.waitUntil(loopCtx, win.End) {+			return false 		}-		o.handleEndOrCleanup(drainCtx, date, nil)+		o.handleEndOrCleanup(drainCtx, date, nil, win)  	case positionDuring:-		// T-1341: recovery surfaces the pending row to the caller. In-memory-		// state isn't rebuilt — handleEnd reads readings directly and uses-		// the pending row's StartE* fields for diagnostic snapshot population.+		// recoverMidWindow surfaces the pending row when one exists so its+		// StartE* fields populate the diagnostic snapshot. Its absence no+		// longer forfeits the day (Q36); WriteOffpeakIfPendingOrAbsent is+		// what guards against overwriting a row a peer already finalised. 		pending := o.recoverMidWindow(drainCtx, date)-		if pending != nil {-			if !o.waitUntil(loopCtx, wallClockTime(now, o.cfg.Location, o.cfg.OffpeakEnd)) {-				return-			}-			o.handleEndOrCleanup(drainCtx, date, pending)-		} else {-			slog.Info("offpeak: no pending record found, skipping today", "date", date)+		if !o.waitUntil(loopCtx, win.End) {+			return false 		}+		o.handleEndOrCleanup(drainCtx, date, pending, win)  	case positionAfter:-		// T-1341 AC 3.4: restart between offpeak-end and 24:00. If a pending-		// row exists, finalise it now (skip boundary wait — the boundary is-		// in the past). Otherwise log+skip. recoverAfterWindow follows the-		// scheduler's log-and-continue convention; failures are logged inside.-		o.recoverAfterWindow(drainCtx, date)+		// Restart between window-end and midnight: finalise now rather than+		// waiting a day. recoverAfterWindow follows the scheduler's+		// log-and-continue convention; failures are logged inside.+		o.recoverAfterWindow(drainCtx, date, win) 	}+	return true+} -nextDay:-	// Daily loop: wait for tomorrow's start, then repeat.-	for {-		o.resetState()-		tomorrow := o.now().In(o.cfg.Location).AddDate(0, 0, 1)-		date = tomorrow.Format(dateLayout)-		startTime := wallClockTime(tomorrow, o.cfg.Location, o.cfg.OffpeakStart)--		if !o.waitUntil(loopCtx, startTime) {-			return-		}--		if err := o.handleStart(drainCtx, date); err != nil {-			slog.Error("offpeak start failed", "date", date, "error", err)-			continue-		}--		endTime := wallClockTime(tomorrow, o.cfg.Location, o.cfg.OffpeakEnd)-		if !o.waitUntil(loopCtx, endTime) {-			return-		}+// resolveWindow resolves the free window of the plan pricing date (AC 4.1)+// into absolute local boundaries on the given day.+//+// The three outcomes are deliberately distinct: (window, true, nil) when a+// plan with a free band covers the date; (_, false, nil) when no plan covers+// it or its plan has no free band — both semantic absences the caller treats+// as "nothing to process"; and a non-nil error when the plan data could not+// be read, which AC 4.6 forbids collapsing into "no plan".+func (o *OffpeakScheduler) resolveWindow(ctx context.Context, day time.Time, date string) (offpeakWindow, bool, error) {+	plans, err := o.plans.Plans(ctx)+	if err != nil {+		return offpeakWindow{}, false, fmt.Errorf("resolve free window for %s: %w", date, err)+	}+	startMin, endMin, ok := plan.FreeWindow(plans, date)+	if !ok {+		return offpeakWindow{}, false, nil+	}+	return offpeakWindow{+		Start:     wallClockTime(day, o.cfg.Location, time.Duration(startMin)*time.Minute),+		End:       wallClockTime(day, o.cfg.Location, time.Duration(endMin)*time.Minute),+		StartHHMM: plan.FormatBandTime(startMin),+		EndHHMM:   plan.FormatBandTime(endMin),+	}, true, nil+} -		o.handleEndOrCleanup(drainCtx, date, nil)+// planRetry returns the configured plan-retry wait, substituting the default+// for the zero value so a hand-constructed scheduler cannot busy-loop.+func (o *OffpeakScheduler) planRetry() time.Duration {+	if o.planRetryInterval <= 0 {+		return defaultPlanRetryInterval 	}+	return o.planRetryInterval }  // handleStart captures the start snapshot and writes a pending record.@@ -174,28 +247,39 @@ func (o *OffpeakScheduler) handleStart(ctx context.Context, date string) error { 	return nil } +// offpeakStart is the diagnostic start-of-window snapshot. It is a pointer+// at every call site because it can legitimately be absent: a plan-read+// failure at window start means handleStart never ran, and a restart past+// the boundary may find no pending row. The readings integration never+// needed it (offpeak-from-readings Decision 2), so its absence costs+// forensics, not the day (Q36).+type offpeakStart struct {+	energy alphaess.EnergyData+	soc    float64+}+ // handleEnd finalises the day's off-peak row by integrating the readings-// table over the SSM window (specs/offpeak-from-readings T-1341).+// table over the plan's free window for that date. // // When pending is non-nil (post-restart recovery path) its StartE* fields // populate the diagnostic start snapshot; otherwise handleEnd uses the-// in-memory state captured by handleStart in this process. One of the two-// must be set — both nil indicates a programming error and returns immediately.+// in-memory state captured by handleStart in this process. With neither, the+// row is finalised from readings alone and the snapshot fields stay zero. //-// The boundary-wait step is skipped automatically when offpeak-end is already+// The boundary-wait step is skipped automatically when window-end is already // in the past (positionAfter recovery): probing for an at-or-after-boundary // reading would only burn the budget on a moot wait. // // Flow (matches design.md "Window-end finalisation state machine"): //  1. Capture the AlphaESS end snapshot (Decision 2 — diagnostic only).-//  2. Wait up to endWaitBudget for a reading at-or-after offpeak-end+//  2. Wait up to endWaitBudget for a reading at-or-after window-end //     (AC 3.1), unless the boundary is already in the past.-//  3. Strongly-consistent query of readings over [offpeak-start, offpeak-end).+//  3. Strongly-consistent query of readings over [windowStart, windowEnd). //  4. Integrate the five deltas via derivedstats.IntegrateOffpeakDeltas. //  5. Conditional write with WriteOffpeakIfPendingOrAbsent — fails only when //     a concurrent writer (backfill CLI) reached `complete` first; in that //     case we log+skip and accept the other writer's value (AC 3.5).-func (o *OffpeakScheduler) handleEnd(ctx context.Context, date string, pending *dynamo.OffpeakItem) error {+func (o *OffpeakScheduler) handleEnd(ctx context.Context, date string, pending *dynamo.OffpeakItem, win offpeakWindow) error { 	energy, soc, err := o.captureSnapshot(ctx, date) 	if err != nil { 		return fmt.Errorf("capture end snapshot: %w", err)@@ -204,30 +288,10 @@ func (o *OffpeakScheduler) handleEnd(ctx context.Context, date string, pending * 	// Resolve the diagnostic start snapshot. The integration over readings 	// is the source of truth for the five deltas (Decision 2); the start 	// snapshot is retained only for drift logging and operator forensics.-	startSnap := o.startSnapshot-	startSoc := o.socStart-	if startSnap == nil {-		if pending == nil {-			// Unreachable in production: both Run() and recoverAfterWindow-			// only call handleEnd with either in-memory state (handleStart-			// ran in this process) or a non-nil pending row.-			return fmt.Errorf("handleEnd: no start snapshot available for %s (no in-memory state, no pending row)", date)-		}-		startSnap = &alphaess.EnergyData{-			Epv: pending.StartEpv, EInput: pending.StartEInput, EOutput: pending.StartEOutput,-			ECharge: pending.StartECharge, EDischarge: pending.StartEDischarge,-			EGridCharge: pending.StartEGridCharge,-		}-		startSoc = pending.SocStart-	}+	start := o.resolveStartSnapshot(date, pending)+	windowStart, windowEnd := win.Start, win.End -	// Resolve boundary window from cfg in Sydney local time. The wall-clock-	// helper exists already; we re-use it so DST handling stays uniform.-	day, _ := time.ParseInLocation(dateLayout, date, o.cfg.Location)-	windowStart := wallClockTime(day, o.cfg.Location, o.cfg.OffpeakStart)-	windowEnd := wallClockTime(day, o.cfg.Location, o.cfg.OffpeakEnd)--	// Skip the boundary-wait when offpeak-end is already in the past: the+	// Skip the boundary-wait when window-end is already in the past: the 	// at-or-after-boundary reading either already exists or never will, and 	// the wait would just burn its budget on a moot probe. This subsumes the 	// former skipBoundaryWait parameter — the positionAfter recovery path@@ -273,8 +337,7 @@ func (o *OffpeakScheduler) handleEnd(ctx context.Context, date string, pending * 		slog.Warn("offpeak integration produced zero usable samples; writing zero-delta row", 			"date", date, "readingsCount", len(readings)) 	}-	item := buildOffpeakRow(o.cfg.Serial, date, startSnap, energy,-		startSoc, soc, deltas, o.now().UTC())+	item := buildOffpeakRow(o.cfg.Serial, date, start, energy, soc, deltas, o.now().UTC(), win)  	dynamo.LogOffpeakDrift(date, item) @@ -288,13 +351,35 @@ func (o *OffpeakScheduler) handleEnd(ctx context.Context, date string, pending * 	}  	slog.Info("offpeak end captured",-		"date", date, "socStart", startSoc, "socEnd", soc,+		"date", date, "window", win.StartHHMM+"-"+win.EndHHMM,+		"socStart", item.SocStart, "socEnd", soc, 		"gridUsageKwh", item.GridUsageKwh, "solarKwh", item.SolarKwh, 		"sampleCount", item.IntegrationSampleCount, 		"skippedPairs", item.IntegrationSkippedPairs) 	return nil } +// resolveStartSnapshot picks the diagnostic start snapshot from in-memory+// state (handleStart ran in this process) or the pending row (post-restart+// recovery), and returns nil when neither exists.+func (o *OffpeakScheduler) resolveStartSnapshot(date string, pending *dynamo.OffpeakItem) *offpeakStart {+	if o.startSnapshot != nil {+		return &offpeakStart{energy: *o.startSnapshot, soc: o.socStart}+	}+	if pending != nil {+		return &offpeakStart{+			energy: alphaess.EnergyData{+				Epv: pending.StartEpv, EInput: pending.StartEInput, EOutput: pending.StartEOutput,+				ECharge: pending.StartECharge, EDischarge: pending.StartEDischarge,+				EGridCharge: pending.StartEGridCharge,+			},+			soc: pending.SocStart,+		}+	}+	slog.Info("offpeak: finalising from readings alone (no start snapshot, no pending row)", "date", date)+	return nil+}+ // integrateReadings converts a []dynamo.ReadingItem to []derivedstats.Reading // and runs the off-peak integration over [windowStart, windowEnd). Empty or // sparse readings produce zero-valued OffpeakDeltas with SampleCount == 0@@ -321,22 +406,26 @@ func integrateReadings(readings []dynamo.ReadingItem, windowStart, windowEnd tim // startE*/endE* snapshot fields are retained as diagnostics only (Decision 2). // Rounded to two decimal places (AC 7.7) so the poller and the backfill CLI // produce byte-equal values for the same readings.+//+// The row snapshots the window geometry it was integrated under, so a later+// plan edit that moves the free window is detectable as a mismatch rather+// than silently repricing the day (Q23/Q31).+//+// A nil start leaves the StartE*, SocStart, and BatteryDeltaPercent fields+// zero: with no start-of-window reference the SoC delta is unknown, and+// reporting socEnd − 0 would read as a full-battery swing that never happened. func buildOffpeakRow( 	serial, date string,-	start, end *alphaess.EnergyData,-	socStart, socEnd float64,+	start *offpeakStart,+	end *alphaess.EnergyData,+	socEnd float64, 	deltas derivedstats.OffpeakDeltas, 	integratedAt time.Time,+	win offpeakWindow, ) dynamo.OffpeakItem {-	return dynamo.OffpeakItem{+	item := dynamo.OffpeakItem{ 		SysSn: serial, Date: date, Status: dynamo.OffpeakStatusComplete,-		StartEpv:        start.Epv,-		StartEInput:     start.EInput,-		StartEOutput:    start.EOutput,-		StartECharge:    start.ECharge,-		StartEDischarge: start.EDischarge, StartEGridCharge: start.EGridCharge,-		SocStart: socStart,-		EndEpv:   end.Epv, EndEInput: end.EInput, EndEOutput: end.EOutput,+		EndEpv: end.Epv, EndEInput: end.EInput, EndEOutput: end.EOutput, 		EndECharge: end.ECharge, EndEDischarge: end.EDischarge, EndEGridCharge: end.EGridCharge, 		SocEnd:                  socEnd, 		GridUsageKwh:            derivedstats.RoundEnergy(deltas.GridImportKwh),@@ -344,11 +433,23 @@ func buildOffpeakRow( 		BatteryChargeKwh:        derivedstats.RoundEnergy(deltas.BatteryChargeKwh), 		BatteryDischargeKwh:     derivedstats.RoundEnergy(deltas.BatteryDischargeKwh), 		GridExportKwh:           derivedstats.RoundEnergy(deltas.GridExportKwh),-		BatteryDeltaPercent:     socEnd - socStart, 		IntegrationSampleCount:  deltas.SampleCount, 		IntegrationSkippedPairs: deltas.SkippedPairs, 		IntegratedAt:            integratedAt.Format(time.RFC3339),+		WindowStart:             win.StartHHMM,+		WindowEnd:               win.EndHHMM, 	}+	if start != nil {+		item.StartEpv = start.energy.Epv+		item.StartEInput = start.energy.EInput+		item.StartEOutput = start.energy.EOutput+		item.StartECharge = start.energy.ECharge+		item.StartEDischarge = start.energy.EDischarge+		item.StartEGridCharge = start.energy.EGridCharge+		item.SocStart = start.soc+		item.BatteryDeltaPercent = socEnd - start.soc+	}+	return item }  // captureSnapshot calls GetOneDateEnergy + GetLastPowerData in parallel with retry.@@ -422,30 +523,30 @@ func (o *OffpeakScheduler) recoverMidWindow(ctx context.Context, date string) *d 	return item } -// recoverAfterWindow handles the positionAfter restart path (T-1341 AC 3.4):-// when the poller starts up between offpeak-end and 24:00 with a pending row,-// run the integration path immediately so the day is finalised without-// waiting another 24 hours for the next start tick. When no row exists or-// the row is already complete, log+skip — no work to do.+// recoverAfterWindow handles the positionAfter path: the process reached this+// day with window-end already behind it, either because it restarted between+// window-end and midnight (T-1341 AC 3.4) or because plan data only became+// readable late in the day (Q36). Either way the day is finalised now rather+// than left for a backfill run. //-// handleEnd internally skips the boundary-wait when offpeak-end is already+// An absent row is no longer a reason to skip: the integration reads the+// readings table, which still holds the window, and the conditional write+// accepts an absent row. Only an already-complete row is left alone.+//+// handleEnd internally skips the boundary-wait when window-end is already // in the past (which it is by definition on this path), so there's no need // for an explicit override.-func (o *OffpeakScheduler) recoverAfterWindow(ctx context.Context, date string) {+func (o *OffpeakScheduler) recoverAfterWindow(ctx context.Context, date string, win offpeakWindow) { 	item, err := o.store.GetOffpeak(ctx, o.cfg.Serial, date) 	if err != nil { 		slog.Warn("offpeak post-window recovery: store query failed", "date", date, "error", err) 		return 	}-	if item == nil {-		slog.Info("offpeak: past window with no pending row, skipping today", "date", date)-		return-	}-	if item.Status == dynamo.OffpeakStatusComplete {+	if item != nil && item.Status == dynamo.OffpeakStatusComplete { 		slog.Info("offpeak: past window with already-complete row, nothing to recover", "date", date) 		return 	}-	if err := o.handleEnd(ctx, date, item); err != nil {+	if err := o.handleEnd(ctx, date, item, win); err != nil { 		slog.Warn("offpeak post-window recovery: handleEnd failed", "date", date, "error", err) 	} }@@ -454,8 +555,8 @@ func (o *OffpeakScheduler) recoverAfterWindow(ctx context.Context, date string) // pending record. The caller passes the pending row (from recoverMidWindow) // when handleStart did not run in this process; otherwise nil and handleEnd // uses in-memory state.-func (o *OffpeakScheduler) handleEndOrCleanup(ctx context.Context, date string, pending *dynamo.OffpeakItem) {-	if err := o.handleEnd(ctx, date, pending); err != nil {+func (o *OffpeakScheduler) handleEndOrCleanup(ctx context.Context, date string, pending *dynamo.OffpeakItem, win offpeakWindow) {+	if err := o.handleEnd(ctx, date, pending, win); err != nil { 		slog.Warn("offpeak end failed, deleting pending record", "date", date, "error", err) 		if delErr := o.store.DeleteOffpeak(ctx, o.cfg.Serial, date); delErr != nil { 			slog.Error("delete pending offpeak failed", "date", date, "error", delErr)@@ -472,7 +573,12 @@ func (o *OffpeakScheduler) resetState() { // waitUntil blocks until the target time or context cancellation. // Returns false if context was cancelled. func (o *OffpeakScheduler) waitUntil(ctx context.Context, target time.Time) bool {-	delay := time.Until(target)+	return o.waitFor(ctx, time.Until(target))+}++// waitFor blocks for the given duration or until context cancellation.+// Returns false if the context was cancelled.+func (o *OffpeakScheduler) waitFor(ctx context.Context, delay time.Duration) bool { 	if delay <= 0 { 		return true 	}@@ -544,14 +650,14 @@ func (o *OffpeakScheduler) waitForReadingAtOrAfter( 	} } -// timePosition returns the current time's position relative to the off-peak window.-func timePosition(now time.Time, start, end time.Duration) windowPosition {-	midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())-	elapsed := now.Sub(midnight)+// positionFor returns now's position relative to the day's resolved free+// window. It compares absolute instants rather than elapsed-since-midnight+// durations, so a DST day's 23 or 25 hours need no special handling.+func positionFor(now time.Time, win offpeakWindow) windowPosition { 	switch {-	case elapsed < start:+	case now.Before(win.Start): 		return positionBefore-	case elapsed < end:+	case now.Before(win.End): 		return positionDuring 	default: 		return positionAfter
internal/poller/plansource_test.go Added +216 / -0
diff --git a/internal/poller/plansource_test.go b/internal/poller/plansource_test.gonew file mode 100644index 0000000..112f893--- /dev/null+++ b/internal/poller/plansource_test.go@@ -0,0 +1,216 @@+package poller++import (+	"context"+	"errors"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// mockPlanLister is a function-field test double for the pricing read+// surface. responses is consumed one entry per call; the last entry repeats+// once exhausted so a test can express "fails twice then succeeds forever".+type mockPlanLister struct {+	responses []planListerResponse+	calls     int+}++type planListerResponse struct {+	items []dynamo.PricingItem+	err   error+}++func (m *mockPlanLister) ListPricing(_ context.Context) ([]dynamo.PricingItem, error) {+	m.calls+++	if len(m.responses) == 0 {+		return nil, nil+	}+	i := min(m.calls-1, len(m.responses)-1)+	return m.responses[i].items, m.responses[i].err+}++func testPricingItem(id, start, end string, defaultRate float64) dynamo.PricingItem {+	savings := 0.35+	item := dynamo.PricingItem{+		PricingID:            id,+		StartDate:            start,+		DefaultRate:          defaultRate,+		Windows:              []dynamo.PricingWindow{{Start: "11:00", End: "14:00", Free: true}},+		FeedInRate:           0.05,+		SavingsReferenceRate: &savings,+	}+	if end != "" {+		item.EndDate = &end+	}+	return item+}++// testPlanSource builds a PlanSource with a backoff short enough that a+// retry sequence costs microseconds rather than seconds.+func testPlanSource(l PlanLister) *PlanSource {+	s := NewPlanSource(l)+	s.retryDelay = time.Microsecond+	return s+}++func TestPlanSource_LoadsPlansFromStore(t *testing.T) {+	lister := &mockPlanLister{responses: []planListerResponse{+		{items: []dynamo.PricingItem{testPricingItem("a", "2026-01-01", "", 0.35)}},+	}}+	src := testPlanSource(lister)++	plans, err := src.Plans(t.Context())++	require.NoError(t, err)+	require.Len(t, plans, 1)+	assert.Equal(t, "a", plans[0].ID)+	assert.Equal(t, 0.35, plans[0].DefaultRate)+	assert.Equal(t, 1, lister.calls)+}++func TestPlanSource_RefreshReplacesCache(t *testing.T) {+	lister := &mockPlanLister{responses: []planListerResponse{+		{items: []dynamo.PricingItem{testPricingItem("old", "2026-01-01", "2026-08-01", 0.35)}},+		{items: []dynamo.PricingItem{+			testPricingItem("old", "2026-01-01", "2026-08-01", 0.35),+			testPricingItem("new", "2026-08-01", "", 0.40),+		}},+	}}+	src := testPlanSource(lister)++	_, err := src.Plans(t.Context())+	require.NoError(t, err)++	plans, err := src.Plans(t.Context())+	require.NoError(t, err)+	require.Len(t, plans, 2)+	assert.Equal(t, "new", plans[1].ID)+}++// A read failure with a warm cache is transient by definition (Q14/AC 4.6):+// the last-good plan set is served so a window boundary is never processed+// as "no plan".+func TestPlanSource_ReadFailureServesLastGoodCache(t *testing.T) {+	buf, restore := captureLog()+	defer restore()++	lister := &mockPlanLister{responses: []planListerResponse{+		{items: []dynamo.PricingItem{testPricingItem("a", "2026-01-01", "", 0.35)}},+		{err: errors.New("dynamo unavailable")},+	}}+	src := testPlanSource(lister)++	_, err := src.Plans(t.Context())+	require.NoError(t, err)++	plans, err := src.Plans(t.Context())+	require.NoError(t, err)+	require.Len(t, plans, 1)+	assert.Equal(t, "a", plans[0].ID)+	assert.True(t, logContains(buf, "serving last-good plan cache"))+}++// A warm cache short-circuits the retry loop: there is a usable answer+// already, so blocking the caller through a backoff sequence buys nothing.+func TestPlanSource_ReadFailureWithCacheDoesNotRetry(t *testing.T) {+	lister := &mockPlanLister{responses: []planListerResponse{+		{items: []dynamo.PricingItem{testPricingItem("a", "2026-01-01", "", 0.35)}},+		{err: errors.New("dynamo unavailable")},+	}}+	src := testPlanSource(lister)++	_, err := src.Plans(t.Context())+	require.NoError(t, err)+	_, err = src.Plans(t.Context())+	require.NoError(t, err)++	assert.Equal(t, 2, lister.calls)+}++// An empty table is a legitimate answer, not a failed read: it caches like+// any other success, so a later blip serves the empty set rather than an error.+func TestPlanSource_EmptyResultCachesAsSuccess(t *testing.T) {+	lister := &mockPlanLister{responses: []planListerResponse{+		{items: nil},+		{err: errors.New("dynamo unavailable")},+	}}+	src := testPlanSource(lister)++	plans, err := src.Plans(t.Context())+	require.NoError(t, err)+	assert.Empty(t, plans)++	plans, err = src.Plans(t.Context())+	require.NoError(t, err)+	assert.Empty(t, plans)+}++func TestPlanSource_ColdStartRetriesWithBackoff(t *testing.T) {+	lister := &mockPlanLister{responses: []planListerResponse{+		{err: errors.New("table unreachable")},+		{err: errors.New("table unreachable")},+		{items: []dynamo.PricingItem{testPricingItem("a", "2026-01-01", "", 0.35)}},+	}}+	src := testPlanSource(lister)++	plans, err := src.Plans(t.Context())++	require.NoError(t, err)+	require.Len(t, plans, 1)+	assert.Equal(t, 3, lister.calls)+}++// Exhausting the cold-start retries surfaces an error. Returning an empty+// plan set instead would read downstream as "no plan prices this day", which+// AC 4.6 forbids.+func TestPlanSource_ColdStartExhaustedRetriesErrors(t *testing.T) {+	lister := &mockPlanLister{responses: []planListerResponse{+		{err: errors.New("table unreachable")},+	}}+	src := testPlanSource(lister)++	plans, err := src.Plans(t.Context())++	require.Error(t, err)+	assert.Nil(t, plans)+	assert.Equal(t, planLoadAttempts, lister.calls)+}++func TestPlanSource_ColdStartRespectsContextCancellation(t *testing.T) {+	lister := &mockPlanLister{responses: []planListerResponse{+		{err: errors.New("table unreachable")},+	}}+	src := NewPlanSource(lister)+	src.retryDelay = time.Hour // long enough that only cancellation ends the wait++	ctx, cancel := context.WithCancel(t.Context())+	cancel()++	_, err := src.Plans(ctx)++	require.Error(t, err)+	assert.Equal(t, 1, lister.calls)+}++// The legacy read transform lives in the dynamo layer, so a pre-migration+// table still yields usable band plans here (Q28).+func TestPlanSource_ServesPlansConvertedFromStoredRows(t *testing.T) {+	lister := &mockPlanLister{responses: []planListerResponse{+		{items: []dynamo.PricingItem{testPricingItem("a", "2026-01-01", "2026-08-01", 0.35)}},+	}}+	src := testPlanSource(lister)++	plans, err := src.Plans(t.Context())+	require.NoError(t, err)++	require.Len(t, plans, 1)+	assert.Equal(t, "2026-08-01", plans[0].EndDate)+	start, end, ok := plans[0].FreeWindowMinutes()+	require.True(t, ok)+	assert.Equal(t, 11*60, start)+	assert.Equal(t, 14*60, end)+}
internal/poller/plansource.go Added +122 / -0
diff --git a/internal/poller/plansource.go b/internal/poller/plansource.gonew file mode 100644index 0000000..d67f11c--- /dev/null+++ b/internal/poller/plansource.go@@ -0,0 +1,122 @@+package poller++import (+	"context"+	"fmt"+	"log/slog"+	"sync"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/plan"+)++const (+	// planLoadAttempts bounds the cold-start retry sequence. With+	// planLoadBaseDelay doubling each round it spans roughly 30 s, long enough+	// to ride out a DynamoDB blip or an IAM propagation delay at container+	// start without pinning a goroutine indefinitely.+	planLoadAttempts = 5++	// planLoadBaseDelay is the first cold-start backoff step.+	planLoadBaseDelay = 2 * time.Second+)++// PlanLister is the pricing read surface the poller needs. It is declared+// here, not imported from the API layer, so the poller depends only on the+// one method it uses — the Lambda keeps sole write access to the table.+type PlanLister interface {+	ListPricing(ctx context.Context) ([]dynamo.PricingItem, error)+}++// PlanSource serves the plan set that drives every window-dependent poller+// behaviour, reading through to the pricing table and caching the last good+// result.+//+// The cache exists to satisfy AC 4.6: a failure to read plan data must never+// be treated as "no plan", because that would silently strip a day of its+// free window and its band split. So a read failure with a warm cache is+// logged and served from the cache, and only a cold start with no cache at+// all can fail — after retrying with backoff (Q14).+//+// Plans is safe for concurrent use: the off-peak scheduler and the+// summarisation pass run in separate goroutines and share one source.+type PlanSource struct {+	lister PlanLister++	mu       sync.RWMutex+	cached   []plan.Plan+	hasCache bool++	// retryDelay is the first cold-start backoff step, overridable so tests+	// don't pay the production wait.+	retryDelay time.Duration+}++// NewPlanSource returns a PlanSource reading through the given lister.+func NewPlanSource(lister PlanLister) *PlanSource {+	return &PlanSource{lister: lister, retryDelay: planLoadBaseDelay}+}++// Plans returns the current plan set. Each call reads through to the store so+// callers see plan edits without waiting for a cache to expire; the read is+// cheap (a handful of rows) and both callers run at most hourly.+//+// A failed read falls back to the last good result. With no cache yet the+// read is retried with backoff, and only an exhausted retry sequence — or a+// cancelled context — returns an error. An empty table is a success, not a+// failure: "no plans configured" is a real state and caches like any other.+func (s *PlanSource) Plans(ctx context.Context) ([]plan.Plan, error) {+	var lastErr error+	for attempt := range planLoadAttempts {+		plans, err := s.load(ctx)+		if err == nil {+			return plans, nil+		}+		lastErr = err++		if cached, ok := s.snapshot(); ok {+			// A usable answer already exists, so blocking the caller through a+			// backoff sequence buys nothing.+			slog.Warn("pricing read failed; serving last-good plan cache",+				"error", err, "plans", len(cached))+			return cached, nil+		}++		if attempt == planLoadAttempts-1 {+			break+		}+		delay := s.retryDelay << attempt+		slog.Warn("pricing read failed with no cached plans; retrying",+			"error", err, "attempt", attempt+1, "retryIn", delay)+		select {+		case <-ctx.Done():+			return nil, fmt.Errorf("load pricing plans: %w", ctx.Err())+		case <-time.After(delay):+		}+	}+	return nil, fmt.Errorf("load pricing plans after %d attempts: %w", planLoadAttempts, lastErr)+}++// load performs one read and, on success, replaces the cache.+func (s *PlanSource) load(ctx context.Context) ([]plan.Plan, error) {+	rows, err := s.lister.ListPricing(ctx)+	if err != nil {+		return nil, fmt.Errorf("list pricing: %w", err)+	}+	plans := dynamo.PlansFromItems(rows)++	s.mu.Lock()+	s.cached = plans+	s.hasCache = true+	s.mu.Unlock()++	return plans, nil+}++// snapshot returns the cached plan set and whether one has ever been loaded.+func (s *PlanSource) snapshot() ([]plan.Plan, bool) {+	s.mu.RLock()+	defer s.mu.RUnlock()+	return s.cached, s.hasCache+}
internal/poller/poller.go Modified +8 / -2
diff --git a/internal/poller/poller.go b/internal/poller/poller.goindex 3aed160..3e91a07 100644--- a/internal/poller/poller.go+++ b/internal/poller/poller.go@@ -61,6 +61,7 @@ type Poller struct { 	client    APIClient 	store     dynamo.Store 	cfg       *config.Config+	plans     *PlanSource 	offpeak   *OffpeakScheduler 	metrics   MetricsRecorder 	evaluator LiveDataEvaluator@@ -74,15 +75,20 @@ type Poller struct { // New creates a Poller with the given dependencies. The metrics recorder // defaults to NoopMetrics; production code overwrites it via the SetMetrics // helper after constructing a CloudWatch client.-func New(client APIClient, store dynamo.Store, cfg *config.Config) *Poller {+//+// The off-peak scheduler and the summarisation pass share one PlanSource so+// they resolve the same window for a given day from the same cached read+// (Decision 2 — the plan is the single source of truth for the free window).+func New(client APIClient, store dynamo.Store, plans PlanLister, cfg *config.Config) *Poller { 	p := &Poller{ 		client:  client, 		store:   store, 		cfg:     cfg,+		plans:   NewPlanSource(plans), 		now:     time.Now, 		metrics: NoopMetrics{}, 	}-	p.offpeak = NewOffpeakScheduler(client, store, cfg)+	p.offpeak = NewOffpeakScheduler(client, store, p.plans, cfg) 	return p } 
Makefile Modified +0 / -2
diff --git a/Makefile b/Makefileindex ac1fefe..42f9fe0 100644--- a/Makefile+++ b/Makefile@@ -139,8 +139,6 @@ docker-dry-run: 		-e ALPHA_APP_ID=$${ALPHA_APP_ID} \ 		-e ALPHA_APP_SECRET=$${ALPHA_APP_SECRET} \ 		-e SYSTEM_SERIAL=$${SYSTEM_SERIAL} \-		-e OFFPEAK_START=11:00 \-		-e OFFPEAK_END=14:00 \ 		-e TZ=Australia/Sydney \ 		flux-poller 
specs/ios-app/implementation.md Modified +1 / -1
diff --git a/specs/ios-app/implementation.md b/specs/ios-app/implementation.mdindex 2fc76ba..0b7ff0e 100644--- a/specs/ios-app/implementation.md+++ b/specs/ios-app/implementation.md@@ -105,7 +105,7 @@ The `ParsedReading` indirection between view model and chart views creates a cle  ### Potential Issues -- **Off-peak window defaults** — when `offpeak` is nil in the status response, the app falls back to `OffpeakData.defaultWindowStart` / `.defaultWindowEnd` ("11:00"/"14:00"). If the backend changes these defaults, the app's fallback values would diverge. A future version could make the backend always return the window times.+- ~~**Off-peak window defaults**~~ — *resolved by `specs/time-of-use-pricing` (Q35).* The `OffpeakData.defaultWindowStart` / `.defaultWindowEnd` constants are gone. `offpeak` is now nullable end to end and a nil window renders as "no window" everywhere, including the widgets — no client ever substitutes a default, because a day whose plan has no free band genuinely has no window to show. - **Fallback data heuristic** — detecting SOC-only data by checking if all power fields are zero is safe in practice (a running household never has zero load across all readings) but theoretically fragile. A backend `dataSource` flag would be more robust. - **`ISO8601DateFormatter` fallback** — `DateFormatting.parseTimestamp` tries fractional seconds first, then falls back to no-fractional-seconds format. If the backend changes timestamp format, both formatters would fail silently (returning nil), causing readings to be dropped from charts. - **No automatic cache pruning** — `CachedDayEnergy` rows accumulate indefinitely. For a personal app this is negligible (365 rows/year at ~100 bytes each), but a cache size limit or age-based pruning would be needed at scale.
specs/OVERVIEW.md Modified +11 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex b5705c7..fc481d9 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -31,6 +31,7 @@ | [Dashboard Simulation](#dashboard-simulation) | 2026-06-09 | Planned | Dashboard what-if toggle that applies a named predetermined load (watts) as a clearly-labelled simulation, showing the effect on house load, battery discharge, and the "empty by" estimate. Computed server-side via a `simulateLoadWatts` query param on `GET /status` (one source of truth — added load raises live `Pload`/`Pbat` and the rolling cutoff, suppresses `cantEmptyBeforeOffpeak`); added load is allocated by a priority waterfall (reduce grid export → battery, capped at the inverter's 5 kW ceiling → grid import) so car charging is accurate in every state — evening peak, mild sun, and full-sun export — validated 1–20000 W. Named presets are a system-wide CRUD resource mirroring `/pricing` (`flux-simulation-presets`, id-only key, synced across devices). Client adds a separate `fetchStatus(simulateLoadWatts:)` so widget/settings paths can't simulate; in-memory active state resets on cold launch; Settings ▸ Simulation editor + a Dashboard Simulate menu and distinct SimulationBanner. iOS + macOS. T-1495. | | [History Period Navigation](#history-period-navigation) | 2026-06-11 | Done | Previous/next stepping, a date-picker jump, and a return-to-current action for the History Wk/Mo ranges, backed by a new past-only `start`/`end` date-range form of `GET /history`. Anchor state in the view model (`nil` = current to-date period); past periods render the full calendar period from stored values only; chart expansion carries the same `HistoryQuery` so enlarged charts match. T-1497. | | [Off-Peak Charge Projection](#off-peak-charge-projection) | 2026-06-13 | Done | During the off-peak window, `/status` returns a projected battery SoC at the window end computed server-side from an idealised two-rate charge curve (4.5 kW to 95%, then 500 W to 100%; independent of live `Pbat` and load simulation), shown as a single contextual row on the Dashboard battery panel that takes precedence over the off-peak delta row. New nullable `projectedEndSoc` on `OffpeakData` (explicit `null` when absent); reuses the `EstimatedCutoff` capacity for consistency; gated like `EstimatedCutoff` (fresh-live, within-window). iOS + macOS. T-1533. |+| [Time-of-Use Pricing](#time-of-use-pricing) | 2026-07-23 | In Progress | Rework pricing plans into daily time bands (default rate + exception windows, e.g. free 10:00–15:00 and cheaper 01:00–06:00) with same-day plan succession via exclusive end dates, making the active plan — not SSM — the source of truth for the free window across the poller and API. Per-band import energy persists at day close so banded costs survive the 30-day readings TTL; a three-tier FluxCore cost resolution (band split → legacy single-rate formula → fallback) keeps historical costs identical; one-time `cmd/migrate-pricing` CLI with golden-value verification converts existing rows. T-1890 + T-1891. |  --- @@ -310,3 +311,13 @@ During the off-peak charging window, `GET /status` returns a projected battery S - [implementation.md](offpeak-charge-projection/implementation.md) - [requirements.md](offpeak-charge-projection/requirements.md) - [tasks.md](offpeak-charge-projection/tasks.md)++## Time-of-Use Pricing++Rework pricing plans into daily time bands with plan succession, for the incoming plan (free 10:00–15:00, cheaper rate 01:00–06:00, standard flat rate otherwise). Plans are stored as entered — `defaultRate` + exception `windows` — with full-day segmentation derived by a shared Go/Swift helper pinned by cross-language test vectors; end dates become exclusive so "old plan ends Aug 1 / successor starts Aug 1" stores the same literal date on both rows. The active plan replaces the SSM off-peak window as source of truth everywhere (poller scheduler re-anchored to midnight, per-day window resolution in `/status`, `/day`, `/history`, backfill CLIs); the summarisation pass persists rated-band import splits (`bandImports`) at day close while the flux-offpeak row keeps exclusive ownership of free-window import, so banded costing outlives the 30-day readings TTL. FluxCore resolves day costs in three tiers (exact band data → the verbatim legacy single-rate formula, which keeps all historical costs identical → conservative fallback). One-time `cmd/migrate-pricing` CLI converts legacy rows with golden-value verification before writing; a transitional read-side transform decouples deploy order from the migration run. T-1890 + T-1891.++- [decision_log.md](time-of-use-pricing/decision_log.md)+- [design.md](time-of-use-pricing/design.md)+- [prerequisites.md](time-of-use-pricing/prerequisites.md)+- [requirements.md](time-of-use-pricing/requirements.md)+- [tasks.md](time-of-use-pricing/tasks.md)
specs/time-of-use-pricing/decision_log.md Added +292 / -0
diff --git a/specs/time-of-use-pricing/decision_log.md b/specs/time-of-use-pricing/decision_log.mdnew file mode 100644index 0000000..6031936--- /dev/null+++ b/specs/time-of-use-pricing/decision_log.md@@ -0,0 +1,292 @@+# Decision Log: Time-of-Use Pricing++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-07-23 | Combine T-1890 and T-1891 into one spec | Plan succession only matters because the new plan's rate structure is arriving; the designs are tightly coupled |+| Q2 | 2026-07-23 | Spec name: `time-of-use-pricing` | Leads with the dominant change; user's pick |+| Q3 | 2026-07-23 | New plan shape: free 10:00–15:00, cheaper rate 01:00–06:00, standard flat rate otherwise | Actual incoming plan, per user |+| Q4 | 2026-07-23 | Successor owns the switch day: plan ending on D and successor starting on D means D is priced by the successor | Matches T-1891's "starts from that same day" phrasing; every day priced by exactly one plan, switch at midnight |+| Q5 | 2026-07-23 | Keep the savings display, valued via an explicit per-plan savings reference rate | User preference; continuity with today's offPeakSavingsRate behaviour with explicit control |+| Q6 | 2026-07-23 | Feed-in stays a single flat rate per plan | Matches the actual plans; no banded exports needed |+| Q7 | 2026-07-23 | Daily supply charge out of scope | Not in either ticket; candidate for a later ticket |+| Q8 | 2026-07-23 | A plan has zero or one free band | All known plans have at most one; keeps window-dependent off-peak features single-window |+| Q9 | 2026-07-23 | Fallback when a day's band split is unavailable: price all import at the plan's highest band rate, no savings | Conservative overestimate mirroring today's all-at-peak fallback; must be identical on every screen per data-consistency rule |+| Q10 | 2026-07-23 | Bands never span midnight; a plan segments 00:00–24:00 | The new plan needs no midnight-spanning band (15:00–24:00 and 00:00–01:00 are separate segments); avoids relaxing the existing midnight-window guard |+| Q11 | 2026-07-23 | Next-window derivations (charge projection, cutoff suppression) use the plan pricing the day the window falls on | On switch eve the successor's window is the one the battery will actually charge in; anchoring to "when the code runs" gives wrong predictions (review finding) |+| Q12 | 2026-07-23 | Paid bands are costing-only; battery features stay tied to the free band | The battery charges in the free window; the cheaper 01:00–06:00 band needs no projection/suppression/masking behaviour. Revisit in a later ticket if wanted |+| Q13 | 2026-07-23 | Per-band splits must outlive the 30-day readings TTL, captured at day close; mechanism decided in design | Otherwise days older than 30 days silently degrade to the fallback forever (review finding); only the cheap band needs new capture — free-band and total imports are already durable |+| Q14 | 2026-07-23 | Poller treats plan-data read failures as transient (last-good schedule / retry), never as "no plan"; Lambda read failures fail the request | An infra blip at a window boundary must not permanently lose a day's split or fabricate an unpriced day |+| Q15 | 2026-07-23 | Fallback days show a $0.00 savings line | Continuity with the existing fallback presentation (daily-costs Decision 16); avoids a display change on a path 5.2 pins as identical |+| Q16 | 2026-07-23 | Band boundaries stay editable after a plan has priced days; stored splits are not recomputed | Rejected immutability-once-priced: a day-one typo in a window must be fixable; AC 4.5 already pins that stored values stand |+| Q17 | 2026-07-23 | All-free plans are rejected (at least one rated band required) | The fallback ("highest band rate") and cost math are undefined on a plan with no rated band |+| Q18 | 2026-07-23 | Coordinated migration cutover, no legacy compatibility layer; verified against recorded pre-migration cost values | Two-user app: record golden values, migrate, verify, update both clients; legacy builds fail safely in the interim |+| Q19 | 2026-07-23 | Sydney timezone named explicitly for plan dates and band boundaries; DST days use wall-clock band membership | Code uses Australia/Sydney throughout; daily-costs' "same TZ as the SSM window" definition dissolves when SSM stops being the source of truth |+| Q20 | 2026-07-23 | Editor captures plans as default rate + exception windows | User pick; matches how retailers describe plans, 3 fields + 2 windows to enter the new plan |+| Q21 | 2026-07-23 | Cost cards keep the current 4-row/4-tile layout | User pick; band detail is not worth the busier card |+| Q22 | 2026-07-23 | Migration via `cmd/migrate-pricing` CLI, dry-run default, aborts unless golden cost check passes | Matches the existing `cmd/backfill-*` operator-tool pattern; verification is built into the same run (AC 5.3) |+| Q23 | 2026-07-23 | Each stored `bandImports` entry snapshots its own geometry (`{start,end,kwh}`) | Self-describing after later plan window edits (Q16); join-by-geometry makes staleness detectable |+| Q24 | 2026-07-23 | Backfill CLIs resolve the window per day from the pricing table instead of `--offpeak-start/end` flags | A backfill spanning the switch date needs per-day windows; static flags would silently misattribute |+| Q25 | 2026-07-23 | Lambda reads the pricing table per request (added to existing errgroups), no caching | Table holds a handful of rows; a Scan is negligible next to the existing four queries. Revisit only if latency data says otherwise |+| Q26 | 2026-07-23 | `Segments` does not merge abutting same-rate segments | Stable geometry keeps the stored-split join deterministic |+| Q27 | 2026-07-23 | Poller's daily cycle re-anchored to midnight: refresh plans, resolve the day's window, then sleep to its start | Plans only change behaviour at midnight (AC 2.2), so one refresh per day is sufficient and the switch-day window is always current |+| Q28 | 2026-07-23 | Dynamo read path converts legacy rows via the migration tool's transform until migration runs; deleted afterwards | Decouples deploy order from the migration run without a permanent dual code path |+| Q29 | 2026-07-23 | `/status` does not carry `bandImports` | Dashboard shows no costs; Day Detail and History get the split from `/day` and `/history` |+| Q30 | 2026-07-23 | Tier 2 of cost resolution is the legacy `DayCosts` formula verbatim (server-peak preference, zero clamp, nil-offpeak path), not the residual | Stored `peakGridImportKwh` differs ~1.5% from `eInput − offpeak`; the residual would change essentially every historical day's cost and abort the migration golden check (review finding) |+| Q31 | 2026-07-23 | flux-offpeak row exclusively owns free-window import; `bandImports` stores rated segments only; offpeak row snapshots its window geometry | One writer per physical quantity (Data Consistency, Q13); avoids a second capture of the same kWh that backfill repairs could desynchronise (review finding) |+| Q32 | 2026-07-23 | `replace-open-ended` rejects (`legacyShape`) when the closing row is still legacy-shape, rather than rewriting it in-transaction | The closing write is a partial UpdateItem; a rewrite needs predecessor state the call doesn't carry and risks clobbering concurrent edits; cutover order already runs migration first (review finding, validator-adjudicated) |+| Q33 | 2026-07-23 | Summarisation pass gains typed per-outcome gating (read failure → retry, no sentinels; no free band → window-independent blocks still run; no plan → terminal until backfill) | The old single early-return would starve socLow/dailyUsage/peak/bands forever on no-window days (review finding) |+| Q34 | 2026-07-23 | Band boundaries parsed by a new parser accepting 24:00; `ParseOffpeakWindow` not reused | `ParseOffpeakWindow` rejects `h > 23` and would reject every plan (review finding) |+| Q35 | 2026-07-23 | `/status.offpeak` becomes nullable; nil means "no window" and clients never substitute the default-window constants | A no-free-band day has no window strings; widget defaults would falsely render the legacy window (review finding) |+| Q36 | 2026-07-23 | `handleEnd`/recovery relaxed to permit readings-only finalisation without a pending row or start snapshot | A plan-read failure at window start must not permanently lose the day (AC 4.6); the snapshot is diagnostics-only since T-1341 |+| Q37 | 2026-07-24 | `CachedDayEnergy` (SwiftData) round-trips `bandImports` and the off-peak source fields | History serves cached days when a fetch fails; without them the same day prices at the banded tier online and the fallback tier offline, which the Data Consistency rule forbids |+| Q38 | 2026-07-24 | `PricingService.periods` renamed to `plans`; `PricingPeriodsView`/`PricingEditor` type names kept | The property is read at every call site and "periods" no longer describes a band-based plan; the view type names carry no semantics and renaming them is churn |+| Q39 | 2026-07-24 | The editor's band-time pickers hold `24:00` as 23:59 and map it back on write | `DatePicker` has no representation for end-of-day, and without the mapping a plan whose last window ends at midnight could not be opened for editing |++## Decision 7: Serve the Off-Peak Row's Geometry and Provenance on /day and /history++**Date**: 2026-07-24+**Status**: accepted++### Context++Decision 6's tier 1 requires the free band's import to be *resolvable*: the off-peak row must have been integrated under the plan's current free window (Q16/Q23), and a sparse-complete row (`integratedAt` set, no samples) counts as unavailable. `plan.DayCosts` implements exactly that in Go, and the shared cost vectors pin FluxCore to the same rule.++But `/day` and `/history` served only `offpeakGridImportKwh` — no geometry, no integration provenance. FluxCore had no way to evaluate the rule. Falling back to the pre-feature default (11:00–14:00) would mean every day priced by the new plan (free 10:00–15:00) failed the geometry check and resolved at tier 3 forever, so the banded costing this feature exists for would never engage.++### Decision++`DaySummary` and `DayEnergy` carry an `OffpeakSource` group — `offpeakWindowStart`, `offpeakWindowEnd`, `offpeakIntegratedAt`, `offpeakSampleCount` — populated from the `flux-offpeak` row (or, for today's live-integrated split, from the resolved window). FluxCore's `OffpeakImport` mirrors `plan.OffpeakRow` field for field, so the shared vectors construct both sides identically.++### Rationale++The join rule already lives in one place per language and is pinned by the vectors; the only thing missing was the input. Mirroring `plan.OffpeakRow` exactly keeps the two implementations comparable by inspection and lets the vector fixtures build the Swift value with no re-derivation. The fields are additive and `omitempty`, so a day with no off-peak split carries none of them.++### Alternatives Considered++- **Infer the geometry from the `bandImports` gap**: the free window is exactly the gap between rated segments, so a split matching the plan's rated geometry implies the row matched too - Rejected because it cannot express the sparse-complete case at all, and it fails the `tier3-offpeak-geometry-mismatch` vector, which is the cross-language pin+- **Drop the off-peak geometry check from tier 1**: fewer wire fields - Rejected: it silently reprices a day whose free window was edited after capture, which Q16 explicitly allows and Q23 exists to make detectable+- **Have the server omit `offpeakGridImportKwh` for unusable rows**: no new fields, and the client's "unusable" case collapses into "no row" - Rejected because it changes what the off-peak card shows on those days, a behaviour change outside this feature's scope++### Consequences++**Positive:**+- Tier 1 actually resolves under the new plan; banded costs are not permanently stuck at the fallback+- Go and Swift cost inputs are field-for-field mirrors, so the shared vectors exercise the real production types on both sides++**Negative:**+- Four more fields on two read payloads, and one more thing to keep in sync if `OffpeakItem`'s provenance changes+- The client now reasons about integration provenance, which was previously described as operator-diagnostics-only++### Impact++`internal/api/response.go` (`OffpeakSource`, `offpeakSourceFrom`), `day.go`, `history.go`; FluxCore `APIModels.swift`, `DayCosts.swift`; the SwiftData cache (Q37).++---++## Decision 4: Store Plans as Default Rate + Exception Windows++**Date**: 2026-07-23+**Status**: accepted++### Context++The band model (Decision 1) needs a storage and wire representation. The requirements demand contiguous full-day coverage (AC 1.1) with validation codes for gaps/overlap/coverage (AC 7.2), and the user chose an editor that captures a default rate plus exception windows.++### Decision++Plans are stored and transmitted as entered: `defaultRate` + `windows` (each free or rated). The full-day segmentation is derived on demand by a shared `plan.Segments` helper (Go) and its FluxCore mirror, pinned to each other by shared test vectors.++### Rationale++Storing the source form round-trips the editor exactly (no lossy reconstruction of "which segment was the default"), and makes gap/coverage violations unrepresentable — uncovered time simply carries the default rate. Validation reduces to window overlap, bounds, precision, and free-band count.++### Alternatives Considered++- **Store derived segment list**: Canonical bands on the wire - Rejected because the editor would need to reconstruct default-vs-exception on open (ambiguous), and gap/coverage validation reappears+- **Fixed columns for the known plan shapes**: Minimal schema - Rejected in Decision 1 already; same reasons apply to the wire shape++### Consequences++**Positive:**+- Editor state == stored state; no derivation on save+- Invalid-by-construction states (gaps, partial coverage) cannot reach the store++**Negative:**+- Segmentation logic exists in both Go and Swift and must be kept identical (mitigated by shared test vectors, the established `note_lengths.json` pattern)++---++## Decision 5: Exclusive End Dates in the New Plan Shape++**Date**: 2026-07-23+**Status**: accepted++### Context++Legacy periods use inclusive end dates (`covers` is `date <= endDate`), and `replace-open-ended` closes the predecessor at `startDate − 1`. Q4 settled that the successor owns the switch day, and T-1891 phrases the flow as "ends that day / starts that same day".++### Decision++New-shape `endDate` is the exclusive switch date: `covers(d) = startDate ≤ d < endDate`. Succession stores the same literal date on both rows. Migration maps legacy inclusive ends to `legacyEnd + 1 day`.++### Rationale++The stored form matches both the user's mental model and the switch-day semantics with no ±1 arithmetic anywhere in validation, succession, or display. Overlap checking becomes standard half-open interval intersection.++### Alternatives Considered++- **Keep inclusive ends, translate in the UI**: No storage semantics change - Rejected because every consumer (overlap check, succession, Swift `covers`, remediation copy) would carry the ±1 translation instead of exactly one place (the migration tool)++### Consequences++**Positive:**+- Succession writes the same date to both rows — directly expresses T-1891+- Half-open intervals compose cleanly with lexicographic date comparison++**Negative:**+- Two end-date semantics exist in the wild until migration runs (bounded by the cutover ordering in AC 5.4)+- Anyone reading raw table data must know which shape a row is (detectable via the `peakRate` attribute)++---++## Decision 6: Three-Tier Cost Resolution++**Date**: 2026-07-23+**Status**: accepted++### Context++Banded costing needs a per-day import split, which only exists for days closed after this feature ships. Historical days have the two-way off-peak split (`offpeakGridImportKwh` + durable totals) but no `bandImports`, and AC 5.2 requires their costs to be unchanged.++### Decision++FluxCore resolves a day's cost in order: (1) stored `bandImports` whose geometry exactly matches the day's plan segmentation; (2) when the plan's rated segments share a single rate — every migrated legacy plan — the existing two-way off-peak split; (3) the AC 3.5/3.6 fallback (all import at the highest rate, $0.00 savings).++### Rationale++Tier 2 is what makes migration lossless without backfilling history: a free + single-rate plan's band costs are fully determined by the two-way split, so pre-feature days price identically to today. Tier 1 is the only tier that can price a multi-rate plan exactly; tier 3 is the consistent, conservative floor required by the requirements.++### Alternatives Considered++- **Backfill `bandImports` for all history**: One uniform path - Rejected: readings older than 30 days are gone, so a backfill cannot reach most history; tier 2 covers those days exactly with data that already exists+- **Fallback whenever `bandImports` is absent**: Simplest client - Rejected because it silently changes every historical day's cost, violating AC 5.2++### Consequences++**Positive:**+- AC 5.2 holds with zero data migration of daily-energy rows+- The resolution order is a pure function of one day's data — testable via the shared cross-language vectors++**Negative:**+- Three code paths in the cost helper (bounded: tier 2 reuses the existing legacy formula, tier 3 the existing fallback)++---++## Decision 1: Model Plans as Daily Time Bands++**Date**: 2026-07-23+**Status**: accepted++### Context++The current pricing model gives each period exactly three flat rates (peak, feed-in, off-peak savings), with the free window held in SSM configuration outside the plan. The incoming plan has a free window plus two different import rates at different times of day, which the three-rate model cannot express. The `daily-costs` spec explicitly deferred time-of-use bands as out of scope, anticipating this change might come.++### Decision++A plan is an ordered set of contiguous, non-overlapping time bands exactly covering the 24-hour day, each carrying an AUD/kWh rate or marked free, plus a single flat feed-in rate and (when a free band exists) a savings reference rate.++### Rationale++The band model expresses both the current plan (free window + one flat rate) and the new plan (free window + two rates) in one schema, so there is a single validation, cost-computation, and UI path. A future plan with different windows or an extra rate needs no schema change. This supersedes the `daily-costs` decision that rejected time-of-use bands — the situation it anticipated ("close one period and start another when the real-world contract changes") has now arrived, but the contract change also changed the rate structure, which that decision did not cover.++### Alternatives Considered++- **Fixed shape (free window + two named rates)**: Models exactly the new plan - Rejected because the next plan shape change would force another schema migration, and the old plan would still need a degenerate mapping+- **Flat rate + optional second rate window**: Minimal delta on the current model - Rejected because it special-cases every consumer (validation, cost math, UI) for one plan generation and still cannot express a third band++### Consequences++**Positive:**+- One schema and one code path for all past and future plans+- Cost computation is a uniform sum over bands+- The free window becomes a property of the plan, enabling Decision 2++**Negative:**+- Larger one-time change: model, validation, API payloads, editor UI, and cost math all change shape+- Requires migrating existing rows (Decision 3)++---++## Decision 2: The Active Plan Is the Source of Truth for the Free Window++**Date**: 2026-07-23+**Status**: accepted++### Context++The free/off-peak window (11:00–14:00) lives in SSM parameters consumed by the poller (window boundary processing, off-peak integration) and the Lambda API (off-peak splits, charge projection, cutoff suppression, peak masking). With band-based plans, the free window is a property of the plan — and the new plan moves it to 10:00–15:00 on the switch date. Keeping SSM authoritative would mean two definitions of the window that must be manually kept in sync exactly when they diverge.++### Decision++All window-dependent features derive the free window from the free band of the plan that prices the day in question. Separately maintained window configuration ceases to be the source of truth.++### Rationale++One definition of the window, switching automatically at the succession boundary, with per-day correctness for historical dates. This matches the project's data-consistency rule: a value used on multiple screens must come from one source. The manual alternative would have required an SSM update at the exact switch date and would still misattribute the window for historical days after the change.++### Alternatives Considered++- **Keep SSM authoritative for the poller, plan bands for pricing only**: Smaller scope - Rejected because the window would be defined twice, and off-peak stats would silently disagree with pricing after a plan switch until someone edits SSM+- **Defer to a follow-up ticket**: Ship pricing first - Rejected by the user; the switch date is known and near, so the window change must be handled in the same feature++### Consequences++**Positive:**+- The plan switch date changes rates and window together, atomically, per day+- Removes a manually synchronised configuration value++**Negative:**+- The poller gains a dependency on the pricing data (it currently never touches `flux-pricing`)+- Window boundary scheduling must follow plan data rather than static configuration, including across a switch date++### Impact++Touches the poller's off-peak jobs, the API's off-peak env-var configuration, and every window consumer listed in requirement 4.1.++---++## Decision 3: Migrate Existing Periods to the Band Model Once++**Date**: 2026-07-23+**Status**: accepted++### Context++The `flux-pricing` table holds existing three-rate periods that price historical days. The band model changes the row shape. Either the old shape remains supported alongside the new one, or existing rows are converted once.++### Decision++Existing periods are migrated once into the band model (free band 11:00–14:00 matching the window their historical data was computed under, flat-rate band for the remainder, feed-in unchanged, savings reference rate = former off-peak savings rate). The legacy shape is not accepted or served afterwards.++### Rationale++The migrated representation is semantically identical for costing (requirement 5.2 pins historical cost equality), so keeping the legacy shape would buy nothing except permanent dual code paths in validation, cost math, and UI for a two-user app with a handful of pricing rows.++### Alternatives Considered++- **Support both shapes indefinitely**: No migration risk - Rejected because every consumer would carry two code paths forever, for data that converts losslessly+- **Versioned rows with on-read conversion**: Convert lazily - Rejected as needless machinery for a table with a handful of rows; a one-time conversion is simpler and verifiable++### Consequences++**Positive:**+- One schema everywhere after cutover+- Historical costs provably unchanged (5.2 is testable)++**Negative:**+- Requires a one-time, verified migration step during rollout+- Older app builds that only speak the three-rate shape stop working against the migrated API (acceptable: two users, same household)++---
specs/time-of-use-pricing/design.md Added +188 / -0
diff --git a/specs/time-of-use-pricing/design.md b/specs/time-of-use-pricing/design.mdnew file mode 100644index 0000000..9562e0f--- /dev/null+++ b/specs/time-of-use-pricing/design.md@@ -0,0 +1,188 @@+# Design: Time-of-Use Pricing++## Overview++Pricing plans become "default rate + exception windows" (stored as entered, full-day segmentation derived), with exclusive end dates for same-day succession, the plan replacing SSM as the source of the free window, and per-band import energy persisted at day close so banded costs survive the 30-day readings TTL. Existing rows are migrated once by a CLI tool with golden-value verification.++## Architecture++### New package: `internal/plan`++Leaf package (no imports from `dynamo`/`api`, mirroring `derivedstats` layering) holding the plan domain: types, validation, segmentation, per-date plan selection, free-window resolution. Consumed by the Lambda API, the poller, the backfill CLIs, and the migration tool. `dynamo.PricingItem` ⇄ `plan.Plan` conversion lives in `internal/dynamo/pricing.go`.++### Storage shape: what the user entered, not derived segments++A plan stores `defaultRate` + `windows` (the exceptions). Segmentation into contiguous bands is derived on demand by `plan.Segments`. Rationale: round-trips the editor exactly, makes gaps/coverage violations unrepresentable (a gap is just the default rate), and leaves only overlap/range checks to validate. Requirements 1.1/1.7's gap and coverage error codes are satisfied by construction.++### End-date semantics change: exclusive++New-shape `endDate` is the switch date (exclusive): `covers(d) = startDate ≤ d < endDate`. "Old plan ends Aug 1, successor starts Aug 1" is stored literally, satisfying AC 2.2 with no ±1 arithmetic. `ReplaceOpenEnded` sets `closing.endDate = successor.startDate` (the `previousDate` helper and the inclusive-overlap check are deleted). Because its closing write is a partial `UpdateItem` (every other write path is a full-item `Put`), running it against a not-yet-migrated legacy row would produce a row that legacy-detects as inclusive while carrying an exclusive end date — the read transform and the migration would then each add +1 day. Guard: `replace-open-ended` rejects with `legacyShape` when the closing row still carries `peakRate` ("run migration first"); rejected over rewrite-in-transaction because `ReplaceOpenEnded` carries no predecessor state (a rewrite needs an extra read and can clobber a concurrent edit), and the cutover order already sequences migration before succession. Overlap validation becomes half-open interval intersection, and `Validate` rejects `endDate ≤ startDate` (exclusive ends make `endDate == startDate` a zero-day plan). Migration maps legacy inclusive ends to `legacyEnd + 1 day` (AC 5.2: no day gained or lost). Swift `covers(date:)` changes from `<=` to `<`.++### Free window from plans: consumer audit++Every consumer of the SSM window (`cfg.OffpeakStart/End`, `OFFPEAK_START/END`, handler `offpeakStart/End` fields). "Plan-derived" means: resolve the plan covering the relevant date, take its free window (`plan.FreeWindow(plans, date)`). The absent-window outcomes are NOT the old unparseable-window no-op — they are decomposed per consumer (see the summarisation table and the nullable `/status.offpeak` below), because that no-op path returns before window-independent work runs and sets no sentinels.++| Consumer | Site | Change |+|---|---|---|+| Poller off-peak scheduler | `internal/poller/offpeak.go` `Run()` | Per-day window from `PlanSource` each daily cycle; no free band that day → sleep to next midnight |+| Poller summarisation pass | `internal/poller/dailysummary.go` step 2 | Window + segments from plan covering `date`; also gains the band-split block (below) |+| Lambda `/status` | `internal/api/status.go` (`buildOffpeak`, cutoff suppression) | Plans fetched in the existing errgroup; window for today from today's plan |+| Cutoff suppression next-window | `internal/api/compute.go` `nextOffpeakStart` | Uses the plan covering the day the window falls on (today vs tomorrow) — AC 4.2/Q11 |+| Lambda `/day` | `internal/api/day.go` | Per-request plan fetch; window for the requested date's plan |+| Lambda `/history` | `internal/api/history.go` | One plan fetch; per-day window when computing today's blocks/split |+| `derivedstats` (Blocks, PeakPeriods, integrators) | `internal/derivedstats/*` | Unchanged — they already take window params; callers change |+| Lambda env/config | `cmd/api/main.go`, `internal/api/handler.go` | Drop `OFFPEAK_START/END` env + handler fields; handler already holds the pricing store |+| Poller config | `internal/config/config.go`, `cmd/poller/logging.go` | Drop `OffpeakStart/End` fields + validation + log line |+| Infra | `infrastructure/template.yaml` | Drop `OffpeakStartParameter/OffpeakEndParameter` + both containers' env vars; add `TABLE_PRICING` env + read-only IAM (`dynamodb:Scan`, `GetItem`, `Query` — `ListPricing` is a Scan) on `PricingTable` to the ECS TaskRole (Lambda keeps sole write access) |+| Backfill CLIs | `cmd/backfill-grid`, `cmd/backfill-solar` | Replace `--offpeak-start/end` flags with per-day plan resolution from the pricing table (a backfill spanning the switch date needs per-day windows; static flags would silently misattribute). `backfill-grid` additionally rewrites the day's rated `bandImports` and the offpeak row's window geometry whenever it repairs that day (writes across the two items are separate calls — non-atomic but idempotent and re-runnable; known limitation) |+| SoC alert evaluation | `internal/poller/eval`, `apns` | No window usage (verified) — untouched |+| Day Detail chart shading | `Flux/Flux/DayDetail/DayChartDomain.swift` `offpeakRange` | Hardcodes 11:00–14:00 (`+11h`/`+14h`) — must take the day's window from the API response; no window → no shading |+| Widget/`OffpeakData` defaults | FluxCore `APIModels.swift` (`defaultWindowStart/End`) | `offpeak: null` must render as "no window", never substitute the default window constants |++### Poller: PlanSource++`internal/poller/plansource.go`: loads all plans at startup, caches last-good (Q14/AC 4.6). Read failure → warn + serve cache, never "no plan". Cold start with unreachable table → retry with backoff; until the first successful load the scheduler defers window processing (the existing `positionAfter` recovery + backfill CLI are the repair paths, so a deferred day is recoverable within the 30-day TTL).++The scheduler's daily cycle is re-anchored to midnight: wake at local midnight, refresh plans, resolve that day's window, then sleep to its start (today it sleeps directly to the next window start using static config). Since plans only change behaviour at midnight boundaries (AC 2.2), one refresh per day is exactly sufficient; a same-day edit to today's free window takes effect only for windows after the edit is picked up, with the backfill CLI as the repair path. The summarisation pass and Lambda always read plans per invocation, so they see edits immediately.++### Band-split capture at day close++Ownership rule (one writer per physical quantity, per Q13): **the flux-offpeak row exclusively owns free-window import** (`GridUsageKwh`, written by the scheduler at window end and repaired by `backfill-grid`); **`bandImports` stores rated segments only**. The offpeak row gains `windowStart`/`windowEnd` geometry attributes snapshotted at capture so a later free-window edit is detectable as a mismatch; rows without geometry (pre-feature) are treated as 11:00–14:00, the only window they can have been computed under. A sparse-complete offpeak row (`integratedAt` set, `integrationSampleCount == 0`) counts as *unavailable* for costing — a zero-delta artifact, not a measured zero.++`runSummarisationPass` gains a third sentinel-gated block (pattern: peak-from-readings Decision 3): `bandsComputedAt` empty → derive the rated segments of `Segments(plan-of-date)`, integrate `max(pgrid,0)` per rated segment (reusing `IntegrateOffpeakDeltas` per segment window, boundaries from wall-clock `SegmentBounds` — do NOT copy the existing peak block's `dayStart.Add(elapsed)` arithmetic, which is an hour off on DST days; deriving the peak block's boundaries from `SegmentBounds` fixes that latent bug in passing), sum raw integrals before per-entry rounding, write via the extended `UpdateDailyEnergyDerived`. Usability gate: all rated segments must pass the integrator's gate, else the field stays absent and the sentinel is still set (mirrors `PeakGridImportKwh`).++The single early-return the pass uses today for an unresolved window is replaced with typed per-outcome gating:++| Outcome | Blocks run | Sentinels | Result |+|---|---|---|---|+| Plan read failed (no cache) | none | none | `PassResultError` — retried next tick (Q14/AC 4.6) |+| Plan with free band | all (windows from plan) | set | normal |+| Plan without free band | socLow + derived + peak in whole-day-rated mode; off-peak-split values absent (AC 4.4) | set | rated `bandImports` cover the whole day |+| No plan covers the date | window-independent stats only (socLow) | band sentinel left unset | terminal until an explicit backfill within the TTL repairs it |++A semantic absence never returns before window-independent work runs; a read failure never sets sentinels.++Window-start plan failure: if the plan is unknown at window start, `handleStart` cannot run; `handleEnd`/recovery are relaxed to permit readings-only finalisation without a pending row or start snapshot (the integration never needed them — the snapshot is diagnostics-only per offpeak-from-readings Decision 2), so a plan load that succeeds later in the day still finalises the window. Only a day-long outage needs the backfill CLI.++Today's (live) split is computed on demand by `/day` and `/history` from readings, same as `computeTodayEnergy` — one shared helper so both agree (AC 3.4).++### Migration: `cmd/migrate-pricing`++Follows the `cmd/backfill-*` pattern (local CLI, direct DynamoDB), except that reporting is the default and `--apply` is what writes — there is no `--dry-run` flag to forget. Steps: (1) read all pricing rows + all retained daily-energy rows; (2) compute every priced day's costs under the **exact legacy `DayCosts` formula** (the tier-2 table above — server-peak preference, clamp, nil-offpeak path; a "three multiplications" simplification would make the golden check vacuous) and record as goldens; days priced by rows that are already new-shape (edited via the band-aware Lambda pre-migration) are verified band-formula-vs-band-formula and logged as such; (3) transform rows: `defaultRate ← peakRate`, `windows ← [{11:00–14:00 free}]` (AC 5.1), `savingsReferenceRate ← offPeakSavingsRate`, `endDate ← legacyEnd + 1`; (4) recompute all goldens under the band formula and diff — any mismatch aborts before writing (AC 5.2/5.3); (5) `--apply` writes new-shape rows in place (same `pricingId`s, sentinel untouched). Idempotent: already-migrated rows (no `peakRate` attribute) are skipped.++To decouple deploy order from the migration run, the dynamo read path converts legacy rows to `plan.Plan` on read using the same transform function the migration tool uses (defined once, in `internal/dynamo/pricing.go`). A band-aware poller or Lambda deployed before migration therefore still resolves windows and serves plans correctly; writes of the legacy shape are rejected immediately. The read-side conversion becomes dead code once migration has run and is deleted in the cleanup task, keeping Decision 3's no-dual-paths end state.++Cutover order (AC 5.4): deploy band-aware poller + Lambda + apps → run migration → enter the new plan → switch date arrives. Legacy app builds against a migrated API fail decoding `/pricing` and `PricingService` publishes no periods → cost cards hide (existing nil-costs path); no crash, no writes.++## Components and Interfaces++```go+// internal/plan+type Window struct{ Start, End string; Free bool; Rate float64 } // HH:MM; Rate ignored when Free+type Plan struct {+    ID, StartDate string+    EndDate       string  // exclusive; "" = open-ended+    DefaultRate   float64+    Windows       []Window+    FeedInRate    float64+    SavingsRefRate float64 // required iff a free window exists+}+type Segment struct{ Start, End string; Free bool; Rate float64 }++func (p Plan) Covers(date string) bool          // startDate <= date < endDate (lexicographic)+func (p Plan) Validate() []ValidationError      // overlap, bounds, precision, free count, savings rate, windows-consume-whole-day+func Segments(p Plan) []Segment                 // deterministic; contiguous; [0] starts 00:00, last ends 24:00+func PlanFor(plans []Plan, date string) (Plan, bool)+func FreeWindow(plans []Plan, date string) (startMin, endMin int, ok bool)+func SegmentBounds(seg Segment, day time.Time, loc *time.Location) (startUnix, endUnix int64) // wallClockTime-based; DST per AC 3.8+```++Contracts:+- Band time parsing is a new parser accepting `"24:00"` as end-of-day (internally minutes 0–1440). `derivedstats.ParseOffpeakWindow` rejects `h > 23` and must NOT be reused for bands — it would reject every plan.+- `Segments` output tiles the day exactly; adjacent same-rate segments produced by abutting windows are NOT merged (stable geometry for the split join).+- `SegmentBounds` derives boundaries via `time.Date` wall-clock (existing `wallClockTime` semantics), so per-segment integrals over a DST day sum to the whole-day integral (shared boundaries cancel). A boundary inside the repeated/skipped DST hour resolves to whichever occurrence `time.Date` picks — deterministic is enough (AC 3.8's sum invariant holds either way; no real plan has a boundary in 02:00–03:00).+- Cost join rule (FluxCore), in order:+  1. **Banded**: `bandImports` present and its geometry exactly equals the *rated* segments of `Segments(plan-of-day)`, AND the free import is resolvable (plan has no free band, or a usable offpeak row whose geometry matches the plan's free window — sparse-complete rows are unusable) → `importCost = Σ ratedKwh×rate`, `savings = offpeakRowKwh × savingsRefRate`.+  2. **Single-rate legacy formula** — applies when the plan's rated segments share one rate `R`. This is the existing `DayCosts` formula **verbatim** (server-peak preference, zero clamp, nil-offpeak path — NOT the naive residual; the stored `peakGridImportKwh` differs ~1.5% from `eInput − offpeak` by design, and legacy costing prefers it). With `E = eInput ?? 0`, `O = offpeakGridImportKwh`, `P = peakGridImportKwh`, `S = savingsRefRate`:++     | `O` | `P` | importCost | savings |+     |---|---|---|---|+     | present | present | `P × R` | `O × S` |+     | present | absent | `max(0, E − O) × R` | `O × S` |+     | absent | present | `P × R` | `$0.00` |+     | absent | absent | `E × R` | `$0.00` |++     For a single-rate plan this tier always resolves, so tier 3 is reachable only for multi-rate plans. This tier prices all pre-feature history and is what makes AC 5.2 hold without backfill.+  3. **Fallback** (multi-rate plans only): all `eInput` × max segment rate, savings $0.00 (AC 3.5/3.6).++  `feedInIncome = eOutput × feedInRate` and `net = importCost − feedInIncome` in every tier. Energy is frozen at capture — meaning unchanged by plan/rate edits, though an explicit backfill may rewrite it; rates are applied at display, so rate edits reprice history but window edits degrade affected multi-rate days to the fallback (visible and consistent; re-capture within the TTL via backfill if wanted).++```swift+// FluxCore replaces PricingPeriod/PricingPeriodDraft+struct PricingPlan: Codable { id, startDate, endDate?, defaultRate, windows: [PlanWindow], feedInRate, savingsReferenceRate?, createdAt, updatedAt }+struct PlanWindow: Codable { start, end: String; free: Bool; rate: Double? }+// PricingPlanDraft mirrors plan.Validate; segmentation helper mirrors plan.Segments,+// pinned to Go via shared vectors (internal/api/testdata/pricing_segments.json — note_lengths.json pattern)+```++Wire shape (`/pricing` CRUD + replace-open-ended, same routes):++```json+{"id":"…","startDate":"2026-08-01","endDate":null,+ "defaultRate":0.35,+ "windows":[{"start":"10:00","end":"15:00","free":true},+            {"start":"01:00","end":"06:00","free":false,"rate":0.28}],+ "feedInRate":0.05,"savingsReferenceRate":0.35,+ "createdAt":"…","updatedAt":"…"}+```++Read-endpoint additions: `DayEnergy` (history) and `DaySummary` (day) gain nullable `bandImports: [{start,end,kwh}]` (rated segments only). `/status` does not — the Dashboard shows no costs, and Day Detail/History are served by the other two endpoints. `/status.offpeak` becomes a nullable object: `null` on a no-free-band or no-plan day (FluxCore's `OffpeakData.windowStart/windowEnd` are currently non-optional and `buildOffpeak` always emits them — both change; clients render nil as "no window", never the default-window constants). Existing `offpeakGridImportKwh`/`offpeakGridExportKwh` fields stay (off-peak card unchanged). Absent serialises as `null` (project convention).++### UI++`PricingEditor` keeps its sheet structure (dates, open-ended toggle, delete, overlap remediation): the three rate fields become Default rate / Feed-in / Savings reference, plus a Windows section (per row: start/end pickers, Free toggle, rate field when not free; add/remove). `PricingPeriodsView.rateSummary` renders "Free 10:00–15:00 · $0.2800 01:00–06:00 · $0.3500 default". Overlap remediation copy changes from "day before this start date" to the switch-date phrasing. `CostsCard`/`HistoryPeriodCostsCard` are unchanged in layout (user decision); only `DayCosts`/`PeriodCosts` inputs change.++## Data Models++`flux-pricing` new-shape item (same table, same key, sentinel row untouched):++| Attribute | Type | Notes |+|---|---|---|+| `pricingId` | S | PK, unchanged |+| `startDate` | S | inclusive, unchanged |+| `endDate` | S? | **exclusive** switch date; absent = open-ended |+| `defaultRate` | N | 4 dp |+| `windows` | L of M | `{start S, end S, free BOOL, rate N?}` |+| `feedInRate` | N | unchanged semantics |+| `savingsReferenceRate` | N? | present iff a free window exists |+| `createdAt`/`updatedAt` | S | unchanged |++Legacy detection: `peakRate` attribute present.++`flux-daily-energy` additions (written via `UpdateDailyEnergyDerived`, third group):++| Attribute | Type | Notes |+|---|---|---|+| `bandImports` | L of M | `{start S, end S, kwh N}` — **rated segments only** (free import lives on the offpeak row), geometry snapshotted at capture |+| `bandsComputedAt` | S | sentinel, same contract as `peakComputedAt` |++`flux-offpeak` additions:++| Attribute | Type | Notes |+|---|---|---|+| `windowStart`/`windowEnd` | S | HH:MM geometry the row was integrated under, snapshotted at capture; absent = 11:00–14:00 (pre-feature rows) |++## Error Handling++New `PricingValidationReason` codes (server + Swift mirror): `bandWindowInvalid` (bad HH:MM, start ≥ end, out of day), `bandOverlap` (windows intersect), `multipleFreeBands`, `savingsRateMissing`, `noRatedBand` (the free window spans the entire day — violates AC 1.3; a zero-width default remainder is otherwise fine when rated windows tile the rest), `legacyShape` (three-rate payload post-migration, AC 7.3; also returned by `replace-open-ended` when the closing row is still legacy-shape). Legacy-shape detection cannot rely on plain `json.Unmarshal` — it silently drops unknown fields, so a legacy POST would decode as a malformed band plan; detect via the raw JSON/attribute map (`peakRate` key present) on both the write path and row reads. Existing `ratePrecision`/`rateRange`/`overlap`/`secondOpenEnded` codes carry over (`overlap` now half-open; `invertedDates` extends to reject `endDate == startDate`).++Plan-data failures: poller → last-good cache + warn (never "no plan", AC 4.6); Lambda read endpoints → 500 like any other store failure (Q14 — never fabricate an unpriced day); `PricingService` decode failure on legacy builds → empty periods, cost cards hide.++## Testing Strategy++- **`internal/plan`**: table-driven tests for `Validate` (each code), `Segments` (new plan, old plan, no windows, abutting windows), `Covers`/`PlanFor` boundary dates (switch day D, D−1). Property-based (`pgregory.net/rapid`, existing project pattern): generated window sets → segments always tile 00:00–24:00 with no overlap and preserve window rates; and for generated readings + windows, Σ per-segment `IntegrateOffpeakDeltas` grid import = whole-day integral (±ε) including DST-length days.+- **Cross-language vectors**: `internal/api/testdata/pricing_segments.json` + `pricing_costs.json` (inputs → segments; day energy + plan → 4 cost figures) consumed by both Go tests and FluxCore tests, pinning AC 3.1–3.6 to identical numbers on both sides. The cost vectors MUST cover all four tier-2 input combinations (offpeak ±, server peak ±), the zero clamp, a sparse-complete offpeak row (unavailable), a geometry-mismatch day, and the multi-rate fallback — the tier-2 rows are also the migration tool's golden formula, so these vectors are the AC 5.2 proof.+- **Poller**: scheduler tests with a mock PlanSource — window from plan, switch-day change (predecessor window D−1, successor D), no-free-band day, unreachable-then-recovered source; summarisation tests for the band block (sentinel gating, geometry snapshot, usability-gate absence).+- **API**: handler tests for new payload validation codes, legacy-shape rejection, half-open overlap, replace-open-ended same-day semantics, `bandImports` in all three read endpoints, next-window suppression across the switch boundary (AC 4.2).+- **Migration**: golden test with legacy fixture rows + daily-energy fixtures asserting old-formula == new-formula for every day (AC 5.2), idempotence, and dry-run write-nothing.+- **Swift**: `DayCosts`/`PeriodCosts` against the shared vectors; `PricingPlanDraft` validation mirror; editor/view-model tests per existing Settings patterns.
specs/time-of-use-pricing/implementation.md Added +263 / -0
diff --git a/specs/time-of-use-pricing/implementation.md b/specs/time-of-use-pricing/implementation.mdnew file mode 100644index 0000000..bf2606b--- /dev/null+++ b/specs/time-of-use-pricing/implementation.md@@ -0,0 +1,263 @@+# Implementation: Time-of-Use Pricing++Explanation of the T-1890/T-1891 implementation at three levels, plus a+completeness assessment. Covers commits `cd78e35`–`51b616d` and the fixes+applied during pre-push review.++---++## Beginner Level++### What Changed++Flux tracks a home battery and shows what the electricity costs. Until now the+app assumed one price for power, all day, with one free window in the middle+(11am–2pm) when the battery charges for nothing.++A new electricity plan is arriving that doesn't work that way. It has three+different prices depending on the time of day: free from 10am to 3pm, cheap+from 1am to 6am, and normal the rest of the time. The old model couldn't+describe that at all.++So a plan is now a set of **time bands**. You enter a default price plus the+exceptions — "free 10:00–15:00", "$0.28 from 01:00–06:00" — and everything+else costs the default. The system works out the full 24-hour picture from+that.++Two more things changed alongside it:++- **Plans can hand over to each other on a date.** You can enter the new plan+  today with a start date next month; on that date it takes over automatically.+  The old plan's end date and the new plan's start date are the same day, and+  that day belongs to the new plan.+- **The free window now comes from the plan.** It used to be a separate setting+  stored in AWS that somebody had to remember to change. Now it's part of the+  plan, so when the plan switches, the window switches with it.++### Why It Matters++Without this, the day the new plan starts someone would have to hand-edit an+AWS setting at exactly the right moment, and every cost the app showed would be+wrong — it would price the cheap 1am–6am power at the full rate.++There's also a promise being kept: every cost the app has ever shown for a past+day must still show the same number afterwards. A migration tool proves that by+calculating every historical day's cost both the old way and the new way and+refusing to change anything if a single day disagrees.++### Key Concepts++- **Band** (or segment) — a slice of the day with one price. Bands sit+  end-to-end and cover all 24 hours with no gaps and no overlaps.+- **Free window** — the band that costs nothing. The battery deliberately+  charges during it. At most one per plan.+- **kWh** — a unit of energy. A 1000-watt heater running for an hour uses 1 kWh.+- **Integration** — the system takes a power reading every 10 seconds and adds+  them up over a time range to work out the energy used in that range. That's+  how it knows how much power was drawn during each band.+- **Exclusive end date** — a plan that ends on 1 August does *not* price+  1 August; its successor does. Storing it this way means both rows carry the+  same date and nothing has to add or subtract a day.+- **Migration** — a one-off program that rewrites stored data into a new shape.++---++## Intermediate Level++### Changes Overview++Five commits across a Go backend and a Swift app, in dependency order:++1. **`internal/plan`** — a new leaf package holding the plan domain: the band+   model, validation, the derived day segmentation, per-date plan selection,+   free-window resolution, and DST-correct segment bounds. Plus the Go side of+   three-tier cost resolution.+2. **Lambda API** — `/pricing` CRUD speaks the band shape; `/status`, `/day`,+   `/history` resolve the window from plans instead of environment variables;+   `/day` and `/history` gain a `bandImports` split.+3. **Poller and operator tools** — `PlanSource` (read-through with a last-good+   cache), a midnight-anchored off-peak scheduler, per-band capture at day+   close, updated backfill CLIs, and `cmd/migrate-pricing`.+4. **App** — `PricingPlan`/`PricingPlanDraft` replace+   `PricingPeriod`/`PricingPeriodDraft`, the editor gains a Windows section,+   and `DayCosts` implements the same three tiers as Go.+5. **Spec** — requirements, design, decision log (6 ADRs, 39 quick decisions),+   39 tasks.++### Implementation Approach++**Storage is "what the user entered", not what's derived.** A plan stores+`defaultRate` + `windows`. The contiguous full-day band list comes from+`plan.Segments` on demand. This makes gaps and partial coverage+*unrepresentable* — uncovered time simply carries the default rate — so two of+the four validation rules the requirements ask for are satisfied by+construction, and the editor round-trips exactly what was typed.++**Costs resolve in three tiers** (Decision 6):++1. The stored per-band split, when its geometry matches the plan's rated+   segments *and* the free band's import is resolvable.+2. The pre-band single-rate formula, verbatim — applicable whenever the plan's+   rated segments share one rate, which every migrated legacy plan does.+3. All import at the plan's highest rate with no savings.++Tier 2 is the load-bearing one: it's why historical costs are unchanged with no+data backfill. Readings older than 30 days are gone, so a backfill *couldn't*+reach most history; tier 2 prices those days exactly from data that already+exists.++**One physical quantity, one writer.** The `flux-offpeak` row exclusively owns+free-window import; `bandImports` stores rated segments only. Peak grid import+and the band split come from a single integration+(`dynamo.IntegrateRatedBands`) so they cannot disagree.++**The two languages are pinned to each other by shared vectors.** Segmentation+and cost resolution exist in both Go and Swift. `internal/api/testdata/+pricing_segments.json` and `pricing_costs.json` are consumed by tests on both+sides, so a divergence fails a test rather than showing two different numbers+on two screens.++### Trade-offs++- **Exclusive end dates** were chosen over keeping inclusive ends and+  translating in the UI. Every consumer would otherwise carry ±1 arithmetic;+  now exactly one place does (the migration).+- **One-time migration, no compatibility layer.** For a two-user app with a+  handful of pricing rows, permanent dual code paths in validation, cost math,+  and UI buy nothing. Legacy app builds fail safely (no costs shown) in the+  interim.+- **Lambda reads the pricing table per request**, no caching. A Scan of a+  handful of rows inside the existing errgroup is negligible next to the four+  queries already there.+- **Segmentation duplicated across Go and Swift** — accepted, mitigated by the+  shared vectors, because the alternative (server-only costing) would mean the+  app couldn't price a cached day offline.++---++## Expert Level++### Technical Deep Dive++**DST is the sharp edge.** Band boundaries must resolve on the day's wall+clock, not as elapsed minutes from midnight — on Sydney's two transition days+those differ by an hour. `plan.SegmentBounds` uses `time.Date` in the location+so adjacent segments share a boundary instant and per-segment integrals over a+23- or 25-hour day still sum to the whole-day integral. A property test+asserts exactly that invariant across 23/24/25-hour days.++This is not hypothetical: pre-review, `internal/api/compute.go`'s+`offpeakWindow.bounds` still used `dayStart.Add(elapsed)` while+`liveBandImports` in the same file used `SegmentBounds`. On 2026-10-04 the+free-window edge and the band edges beside it would have sat an hour apart,+inside a single response — so today's `peakGridImportKwh` would not have+equalled the sum of `bandImports`. Fixed during review, with+`dst_window_test.go` pinning both the wall-clock hour and the+bounds-equal-segment-bounds invariant.++**Failure semantics are decomposed per outcome, not collapsed.** The+summarisation pass replaced a single early return with typed gating:++| Outcome | Blocks run | Sentinels | Result |+|---|---|---|---|+| Plan read failed | none | none | retried next tick |+| Plan with free band | all | set | normal |+| Plan without free band | window-independent + whole-day-rated | set | rated bands cover the day |+| No plan | window-independent only | band sentinel unset | repairable by backfill |++The distinction that matters: a *semantic* absence never returns before+window-independent work runs, and a *read failure* never sets sentinels. The+old single return would have starved socLow/dailyUsage/peak/bands forever on+any no-window day. A fifth outcome was added during review — once the+window-independent stats exist on a date no plan prices, the pass skips before+querying readings, rather than re-reading ~8,640 rows hourly to compute+nothing.++**Geometry is snapshotted, not assumed.** Each stored `bandImports` entry and+each off-peak row carries the window it was captured under. Plan windows stay+editable after a plan has priced days (Q16), so a later edit must be+*detectable* rather than silently repricing history — the join compares+geometry and degrades to a lower tier on mismatch. A sparse-complete off-peak+row (`integratedAt` set, zero samples) is a zero-delta artifact, not a measured+zero, and counts as unusable for costing.++**The migration verifies itself against an independent implementation.**+`cmd/migrate-pricing/golden.go` re-implements the legacy three-rate formula+rather than calling the shared helper — a check that reuses the code under test+proves nothing. Any day whose cost differs aborts before a single write.+During review its row-decoding was tightened: an undecodable row previously+warned and continued, which would have left an untransformed row, dropped every+day it priced out of the golden check, and exited 0 with a half-migrated table.++### Architecture Impact++- **The poller gains a dependency on `flux-pricing`**, which it never touched+  before. Read-only IAM (`Scan`/`GetItem`/`Query`); the Lambda keeps sole write+  access. `PlanSource` treats read failures as transient and serves last-good —+  never "no plan", because that would silently strip a day of its free window+  and its band split.+- **`internal/plan` is a genuine leaf** (imports only `fmt`, `math`, `sort`,+  `time`), consumed by `api`, `dynamo`, `poller`, and three CLIs. `dynamo` owns+  the `PricingItem ⇄ plan.Plan` conversion.+- **`/status.offpeak` became nullable.** A no-free-band day has no window+  strings to send, and the widget default constants were deleted so no client+  can substitute the legacy window.+- **Two end-date semantics exist in the wild until the migration runs.**+  Bounded by the cutover ordering, detectable via the `peakRate` attribute, and+  `replace-open-ended` refuses to run against a legacy closing row rather than+  producing a double-shifted date.++### Potential Issues++- **Cutover is ordered and manual** (`prerequisites.md`): deploy → migrate →+  enter the new plan → switch date. The migration must complete before the new+  plan is entered. Task 39 (deleting the transitional read transform) is gated+  on it and is intentionally still open.+- **Window edits degrade multi-rate days to the fallback tier.** Energy is+  frozen at capture; rates apply at display. A rate edit reprices history+  cleanly, but a *window* edit invalidates the geometry join for affected+  multi-rate days until a backfill re-captures them (only possible within the+  30-day readings TTL). Visible and consistent, but worth knowing.+- **A day no plan prices is terminal for `dailyUsage`/`peakPeriods`.** The band+  sentinel stays unset so a backfill can repair the split, but the derived+  sentinel is set with those two fields absent, and no tool recomputes the+  five-block panel afterwards. Intended per the design table; worth confirming.+- **Today's rated region is integrated twice per request** —+  `livePeakGridImport` and `liveBandImports` cover the same span, each+  computing five energy channels to use one. The poller deliberately fused+  these; the live path did not. Not a correctness bug now that the boundaries+  agree, but it is wasted work and the two carry different usability gates.++---++## Completeness Assessment++### Fully implemented++- **Requirement 1** (band model), **2** (succession), **3** (cost+  computation), **4** (plan-derived free window), **6** (management UI), **7**+  (pricing API). Every consumer in the design's audit table was verified+  changed: `DayChartDomain.offpeakRange`, the widget default constants, both+  backfill CLIs, `infrastructure/template.yaml` IAM and env, and the+  `OFFPEAK_START`/`OFFPEAK_END` plumbing in `cmd/api` and `internal/config`.+- Cross-language vectors are consumed by both Go (`pricing_vectors_test.go`)+  and Swift (`PlanSegmentsVectorTests`, `DayCostsVectorTests`).++### Partially implemented — by design++- **Requirement 5** (migration). The tool, its golden check, and its tests are+  complete; the *production run* is a prerequisite, not a code task. Task 39+  (removing the transitional legacy read transform) is correctly blocked on it.+  The write-path `legacyShape` rejection is permanent in two places and stays.++### Gaps worth noting++- **AC 6.4** (client validation mirrors server validation) is the one+  cross-language contract with no cross-language pin.+  `PricingPlanDraft.validate` mirrors `plan.Validate` by hand; segmentation and+  costs have shared vectors, validation does not. The two can drift silently.+  A `pricing_validation.json` vector set would close it.+- **`derivedstats.Blocks`** still derives its block boundaries with+  `dayStart.Add(elapsed)`. Pre-existing and outside this feature's scope (that+  package was explicitly unchanged), but it is the same latent DST bug class+  the band capture deliberately retired.
specs/time-of-use-pricing/prerequisites.md Added +10 / -0
diff --git a/specs/time-of-use-pricing/prerequisites.md b/specs/time-of-use-pricing/prerequisites.mdnew file mode 100644index 0000000..5ea359d--- /dev/null+++ b/specs/time-of-use-pricing/prerequisites.md@@ -0,0 +1,10 @@+# Prerequisites for Time-of-Use Pricing++These tasks must be completed by the user before or during implementation. They implement the cutover order from design.md (AC 5.4): deploy band-aware code → migrate → enter the new plan → switch date.++## Before Testing / Cutover++- [ ] Deploy the updated stack (poller image, Lambda, CloudFormation template) — the template change adds the poller's pricing-table read grant and `TABLE_PRICING`, and removes the off-peak SSM parameters and env vars. Until migration runs, the transitional read conversion keeps legacy rows working.+- [ ] Update both installed apps (iOS + macOS) to the band-aware build. Legacy builds fail safely against a migrated API (no costs shown) but should not linger.+- [ ] Run `cmd/migrate-pricing` against production: first with no `--apply` (report-only is the default — review the transform and golden cost verification output), then with `--apply` to write. Note this is the opposite default to the `cmd/backfill-*` tools, which write unless given `--dry-run`; `migrate-pricing` has no `--dry-run` flag and will refuse to start if given one. Must complete before the new plan is entered. **Blocks task 39** (removal of the transitional read conversion).+- [ ] Enter the new plan in the app (end current plan on the switch date, add successor: free 10:00–15:00, cheaper rate 01:00–06:00, default rate otherwise, feed-in and savings reference rates) — before the switch date so the poller picks it up at midnight.
specs/time-of-use-pricing/requirements.md Added +111 / -0
diff --git a/specs/time-of-use-pricing/requirements.md b/specs/time-of-use-pricing/requirements.mdnew file mode 100644index 0000000..1583a2e--- /dev/null+++ b/specs/time-of-use-pricing/requirements.md@@ -0,0 +1,111 @@+# Requirements: Time-of-Use Pricing++Transit tickets: T-1890 (Multiple prices support), T-1891 (New plan support)++## Introduction++Flux currently models an electricity plan as three flat rates (peak, feed-in, off-peak savings), with the free window held in separate SSM configuration. A new plan is arriving with time-of-use pricing — free 10:00–15:00, a cheaper rate 01:00–06:00, and a standard flat rate otherwise — starting on a known future date. This spec reworks pricing plans into daily time bands, adds plan succession so the new plan can be entered ahead of its start date, and makes the plan the single source of truth for the free window.++## Out of Scope++- Daily supply charge on plans (candidate for a later ticket)+- Time-banded feed-in rates — feed-in stays one flat rate per plan+- Bands spanning midnight — a plan segments the 24-hour day; a rate active before and after midnight is expressed as two bands+- Mid-day plan switches — plans change over at midnight local time only+- Retaining the legacy three-rate plan shape after migration+- A compatibility layer for legacy clients — after migration, not-yet-updated apps fail safely (no costs shown) until updated+- Battery features for paid bands — charge projection, cutoff suppression, and peak masking stay tied to the free band; the cheaper 01:00–06:00 band affects costing only+- Automatic tariff import from a retailer+- More than one open-ended plan (existing single-open-ended rule is kept)++## Requirements++### 1. Time-Band Plan Model++**User Story:** As the app owner, I want a plan defined as daily time bands each carrying a rate or marked free, so that the new plan's time-of-use pricing can be represented alongside the existing flat-rate plans.++**Acceptance Criteria:**++1. <a name="1.1"></a>A plan SHALL consist of an ordered set of contiguous, non-overlapping time bands that exactly cover the 24-hour day (00:00–24:00), each band carrying an AUD/kWh rate or marked free  +2. <a name="1.2"></a>Band boundaries SHALL be expressed at minute granularity (HH:MM) and interpreted, like plan dates, in Australia/Sydney local time  +3. <a name="1.3"></a>A plan SHALL contain zero or one free band, and SHALL contain at least one rated (non-free) band  +4. <a name="1.4"></a>A plan SHALL carry a single flat feed-in rate (AUD/kWh)  +5. <a name="1.5"></a>IF a plan has a free band, THEN it SHALL carry a savings reference rate (AUD/kWh) used to value free-window energy  +6. <a name="1.6"></a>All rates SHALL satisfy the existing pricing bounds and precision rules (0 ≤ rate ≤ 10.00, at most 4 decimal places)  +7. <a name="1.7"></a>The system SHALL reject a plan whose bands leave a gap, overlap, or do not cover the full day, identifying the violated rule  +8. <a name="1.8"></a>The model SHALL be able to represent both the current plan (free 11:00–14:00, one flat rate) and the new plan (free 10:00–15:00, cheaper rate 01:00–06:00, standard rate otherwise)  ++### 2. Plan Succession++**User Story:** As the app owner, I want to end the current plan and add a successor that starts the same day, so that the switch to the new plan happens automatically on the right date.++**Acceptance Criteria:**++1. <a name="2.1"></a>Every calendar day (Australia/Sydney local time) SHALL be priced by at most one plan — the plan whose date range covers that day  +2. <a name="2.2"></a>WHEN a plan ends on date D and a successor starts on date D, all of day D SHALL be priced by the successor (the predecessor's last priced day is D−1)  +3. <a name="2.3"></a>The system SHALL accept a successor plan entered in advance of its start date  +4. <a name="2.4"></a>The system SHALL allow ending the open-ended plan by giving it an end date without requiring a successor; days after it SHALL be unpriced until a successor exists  +5. <a name="2.5"></a>The system SHALL reject plans whose date ranges would price the same day twice, identifying the conflicting plan  +6. <a name="2.6"></a>The succession operation (end current plan + create successor) SHALL never expose an intermediate state that violates [2.1](#2.1) or the single-open-ended rule, including under concurrent edits  +7. <a name="2.7"></a>Days not covered by any plan SHALL show no cost data, matching today's unpriced-day behaviour  ++### 3. Time-of-Use Cost Computation++**User Story:** As a user, I want daily and period costs computed from the plan's time bands, so that displayed costs reflect what I actually pay under the new plan.++**Acceptance Criteria:**++1. <a name="3.1"></a>A day's grid import cost SHALL be the sum over the day's bands of (import kWh consumed during the band × the band's rate), with free bands contributing $0, using the bands of the plan that prices that day  +2. <a name="3.2"></a>Feed-in income SHALL be the day's total export kWh × the plan's feed-in rate, and net cost SHALL be import cost minus feed-in income  +3. <a name="3.3"></a>WHEN the day's plan has a free band, savings SHALL be the free band's import kWh × the plan's savings reference rate  +4. <a name="3.4"></a>The per-band energy split for a day SHALL come from a single source, so that every screen showing a cost or band value for that day shows the identical number  +5. <a name="3.5"></a>For every day priced by a banded plan, the per-band import split SHALL remain available for as long as the day's energy figures are retained (i.e. beyond the raw-readings retention window); the fallback of [3.6](#3.6) SHALL be the exception for days whose split was never captured, not the steady state  +6. <a name="3.6"></a>A day's split counts as available only when every band's import kWh is known; WHEN it is unavailable (including partially known), all of that day's import SHALL be priced at the plan's highest band rate and the savings line SHALL show $0.00 (matching the existing fallback presentation), identically on every screen  +7. <a name="3.7"></a>Period cost totals (History) SHALL equal the sum of the per-day costs over the priced days in the period, retaining the existing partial-coverage indication when some days are unpriced  +8. <a name="3.8"></a>On daylight-saving transition days, band membership SHALL follow local wall-clock time (energy in a repeated hour counts toward the band containing that wall-clock time) and the day's band energies SHALL sum to the day's total  ++### 4. Free Window Driven by the Active Plan++**User Story:** As the app owner, I want the free window used by off-peak features to come from the plan active on the relevant day, so that it switches automatically when plans change.++**Acceptance Criteria:**++1. <a name="4.1"></a>WHEN computing new values, features that consume the off-peak/free window (off-peak energy split, charge-window stats, off-peak charge projection, cutoff suppression, peak-period masking) SHALL derive the window from the free band of the plan that prices the day in question, not from separately maintained window configuration  +2. <a name="4.2"></a>WHEN plans change on a switch date, window-dependent behaviour SHALL follow each day's own pricing plan without manual reconfiguration; derivations of the next window (charge projection, cutoff suppression) SHALL use the free band of the plan pricing the day that window falls on, even when that plan is not yet active  +3. <a name="4.3"></a>Off-peak window boundary processing (start/end of the free window) SHALL occur at the free-band times of the plan pricing that day  +4. <a name="4.4"></a>WHEN no plan prices a day being computed, or its pricing plan has no free band, off-peak features SHALL behave as they do today when no off-peak data exists (values absent, not zero); display of already-stored per-day values SHALL be independent of current plan coverage  +5. <a name="4.5"></a>Off-peak values already stored for past days SHALL remain valid and SHALL NOT be retroactively recomputed  +6. <a name="4.6"></a>A failure to read plan data SHALL NOT be treated as "no plan": window boundary processing and band-split capture SHALL tolerate transient plan-data unavailability without permanently losing a day's band split  ++### 5. Migration of Existing Plans++**User Story:** As the app owner, I want existing flat-rate periods converted to the band model once, so that historical costs keep working without dual code paths.++**Acceptance Criteria:**++1. <a name="5.1"></a>Each existing pricing period SHALL be represented in the band model as a free band matching the window its historical data was computed under (11:00–14:00), rated band segments covering the remainder of the day each carrying the former flat rate, the unchanged feed-in rate, and a savings reference rate equal to its former off-peak savings rate  +2. <a name="5.2"></a>Day and period cost values for historical dates SHALL be identical before and after migration, including each closed period's last priced day (legacy inclusive end dates SHALL map to the switch-day semantics of [2.2](#2.2) without gaining or losing a day)  +3. <a name="5.3"></a>Migration SHALL be verified by comparing recorded pre-migration day and period cost values against post-migration output before the legacy shape is removed  +4. <a name="5.4"></a>After migration, the legacy three-rate plan shape SHALL no longer be accepted or served anywhere; migration SHALL be complete before the new plan's switch date, and legacy app builds encountering the band shape SHALL fail safely (no costs shown, no crash, no writes)  ++### 6. Plan Management UI++**User Story:** As a user, I want to view and edit band-based plans in Settings, so that I can enter the new plan and set the switch date before it starts.++**Acceptance Criteria:**++1. <a name="6.1"></a>The pricing settings screen SHALL display each plan's date range and its bands (times and rates, free band identified), replacing the current three-rate summary  +2. <a name="6.2"></a>The plan editor SHALL allow defining a plan's bands (boundaries, per-band rate or free), start and end dates, feed-in rate, and savings reference rate, following the existing pricing editor's validation and error presentation patterns  +3. <a name="6.3"></a>The editor SHALL support the succession flow of [2.2](#2.2)–[2.3](#2.3): ending the current plan on date D and creating the successor starting D  +4. <a name="6.4"></a>Client-side validation SHALL mirror server-side validation rules so that a plan accepted locally is not rejected by the server for a rule the client could have checked  +5. <a name="6.5"></a>Date-range conflicts SHALL offer the existing overlap remediation affordance, adjusted to the switch-day semantics of [2.2](#2.2)  ++### 7. Pricing API++**User Story:** As the app, I want the pricing API to serve and accept band-based plans, so that clients and the backend agree on one model.++**Acceptance Criteria:**++1. <a name="7.1"></a>The pricing API SHALL provide the same capabilities as today (list, create, update, delete, replace-open-ended succession) over the band-based plan shape  +2. <a name="7.2"></a>Validation failures SHALL identify the violated rule in the response, extending the existing pricing error-code pattern to band rules (gap, overlap, coverage, precision, range, free-band count)  +3. <a name="7.3"></a>Requests in the legacy three-rate shape SHALL be rejected with a validation error after migration  
specs/time-of-use-pricing/tasks.md Added +288 / -0
diff --git a/specs/time-of-use-pricing/tasks.md b/specs/time-of-use-pricing/tasks.mdnew file mode 100644index 0000000..2b497cd--- /dev/null+++ b/specs/time-of-use-pricing/tasks.md@@ -0,0 +1,288 @@+---+references:+    - specs/time-of-use-pricing/requirements.md+    - specs/time-of-use-pricing/design.md+    - specs/time-of-use-pricing/decision_log.md+---+# Time-of-Use Pricing++## Foundation: Go plan domain and data layer++- [x] 1. Write failing tests for internal/plan band parsing and plan validation <!-- id:chkfin5 -->+  - New leaf package internal/plan (no dynamo/api imports)+  - Band time parser must accept 24:00 (internal minutes 0-1440) — derivedstats.ParseOffpeakWindow rejects h>23 and must not be reused+  - Cover codes: bandWindowInvalid, bandOverlap, multipleFreeBands, savingsRateMissing, noRatedBand (free window spans whole day; zero-width default remainder is valid), rate bounds 0..10 and 4dp+  - endDate <= startDate rejected (zero-day plan under exclusive ends)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7)++- [x] 2. Implement internal/plan types, band parser, and Validate <!-- id:chkfin6 -->+  - Blocked-by: chkfin5 (Write failing tests for internal/plan band parsing and plan validation)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7)++- [x] 3. Write failing unit and property tests for Segments, Covers, PlanFor, FreeWindow, SegmentBounds <!-- id:chkfin7 -->+  - rapid properties: Segments always tiles 00:00-24:00, no overlap, abutting same-rate segments NOT merged (Q26)+  - Property: sum of per-segment IntegrateOffpeakDeltas grid import equals whole-day integral within epsilon, including 23h/25h Sydney days via wall-clock SegmentBounds (never dayStart.Add elapsed arithmetic)+  - Covers/PlanFor boundaries: switch day D goes to successor, D-1 to predecessor+  - Repeated DST hour: deterministic time.Date resolution is acceptable (design)+  - Blocked-by: chkfin6 (Implement internal/plan types, band parser, and Validate)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.8](requirements.md#1.8), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [3.8](requirements.md#3.8)++- [x] 4. Implement Segments, Covers, PlanFor, FreeWindow, SegmentBounds <!-- id:chkfin8 -->+  - Blocked-by: chkfin7 (Write failing unit and property tests for Segments, Covers, PlanFor, FreeWindow, SegmentBounds)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.8](requirements.md#1.8), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [3.8](requirements.md#3.8)++- [x] 5. Create shared cross-language cost/segment vectors and Go golden-formula tests <!-- id:chkfin9 -->+  - internal/api/testdata/pricing_segments.json + pricing_costs.json, consumed later by FluxCore tests (note_lengths.json pattern)+  - Cost vectors MUST cover: all four tier-2 combos (offpeak +/-, server peak +/-), zero clamp, sparse-complete offpeak row (integratedAt set, sampleCount 0 = unavailable), geometry-mismatch day, multi-rate fallback (max rate, $0.00 savings)+  - Go test implements the tier-2 legacy DayCosts formula table from design.md — the same helper becomes the migrate tool's golden formula+  - Blocked-by: chkfin8 (Implement Segments, Covers, PlanFor, FreeWindow, SegmentBounds)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [5.2](requirements.md#5.2)++- [x] 6. Write failing tests for dynamo new-shape PricingItem and legacy read transform <!-- id:chkfina -->+  - internal/dynamo/pricing.go+  - Legacy detection via raw attribute map (peakRate present) — plain unmarshal silently drops unknown fields+  - Transform shared with cmd/migrate-pricing: defaultRate <- peakRate, windows <- [{11:00-14:00 free}], savingsReferenceRate <- offPeakSavingsRate, endDate <- legacyEnd + 1 day+  - Sentinel row untouched+  - Blocked-by: chkfin6 (Implement internal/plan types, band parser, and Validate)+  - Stream: 1+  - Requirements: [5.1](requirements.md#5.1), [7.3](requirements.md#7.3)++- [x] 7. Implement dynamo PricingItem band shape, raw-map legacy detection, transitional read transform <!-- id:chkfinb -->+  - Blocked-by: chkfina (Write failing tests for dynamo new-shape PricingItem and legacy read transform)+  - Stream: 1+  - Requirements: [5.1](requirements.md#5.1), [7.3](requirements.md#7.3)++- [x] 8. Write failing tests for DailyEnergyItem bandImports group and OffpeakItem window geometry <!-- id:chkfinc -->+  - internal/dynamo/models.go + dynamostore.go+  - bandImports: rated segments only {start,end,kwh} — free import stays on the offpeak row (Q31)+  - bandsComputedAt third sentinel group in UpdateDailyEnergyDerived (peak-from-readings Decision 3 pattern)+  - OffpeakItem windowStart/windowEnd HH:MM snapshot; absent means 11:00-14:00 (pre-feature rows)+  - Stream: 1+  - Requirements: [3.4](requirements.md#3.4), [3.5](requirements.md#3.5)++- [x] 9. Implement daily-energy band group and offpeak geometry fields <!-- id:chkfind -->+  - Blocked-by: chkfinc (Write failing tests for DailyEnergyItem bandImports group and OffpeakItem window geometry)+  - Stream: 1+  - Requirements: [3.4](requirements.md#3.4), [3.5](requirements.md#3.5)++- [x] 10. Write failing tests for ReplaceOpenEnded exclusive-end and legacy-shape rejection <!-- id:chkfine -->+  - internal/dynamo/pricing_transactional.go+  - closing.endDate = successor.startDate (same literal date); delete previousDate helper+  - Closing row still carrying peakRate -> legacyShape error, no partial UpdateItem patch (Q32) — a partial update would create a legacy-detected row with an exclusive end date, double-shifted by transform + migration+  - Blocked-by: chkfinb (Implement dynamo PricingItem band shape, raw-map legacy detection, transitional read transform)+  - Stream: 1+  - Requirements: [2.2](requirements.md#2.2), [2.6](requirements.md#2.6), [5.4](requirements.md#5.4)++- [x] 11. Implement ReplaceOpenEnded same-day succession and legacy guard <!-- id:chkfinf -->+  - Blocked-by: chkfine (Write failing tests for ReplaceOpenEnded exclusive-end and legacy-shape rejection)+  - Stream: 1+  - Requirements: [2.2](requirements.md#2.2), [2.6](requirements.md#2.6), [5.4](requirements.md#5.4)++## Lambda API++- [x] 12. Write failing handler tests for band-based /pricing endpoints <!-- id:chkfing -->+  - internal/api/pricing_handler.go tests; wire shape from design.md+  - Validation codes incl. noRatedBand and legacyShape via raw JSON key check+  - Overlap is half-open interval intersection naming the conflicting plan (AC 2.5)+  - replace-open-ended: same-day succession + legacy reject+  - Future-dated successor and ending the open-ended plan without a successor both accepted; 4KB body cap retained+  - Blocked-by: chkfin8 (Implement Segments, Covers, PlanFor, FreeWindow, SegmentBounds), chkfinf (Implement ReplaceOpenEnded same-day succession and legacy guard)+  - Stream: 1+  - Requirements: [1.7](requirements.md#1.7), [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)++- [x] 13. Implement pricing handlers for the band shape <!-- id:chkfinh -->+  - Blocked-by: chkfing (Write failing handler tests for band-based /pricing endpoints)+  - Stream: 1+  - Requirements: [1.7](requirements.md#1.7), [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)++- [x] 14. Write failing tests for plan-derived windows in status/day/history and nullable offpeak <!-- id:chkfini -->+  - status.go/day.go/history.go/compute.go tests; plans fetched in existing errgroups+  - /status.offpeak serialises null on no-free-band or no-plan day (never default window)+  - nextOffpeakStart uses the free band of the plan pricing the day the window falls on — switch eve must pick the successor window (AC 4.2/Q11)+  - No plan -> off-peak values absent not zero; unpriced days show no cost data (AC 2.7)+  - Blocked-by: chkfin8 (Implement Segments, Covers, PlanFor, FreeWindow, SegmentBounds), chkfinb (Implement dynamo PricingItem band shape, raw-map legacy detection, transitional read transform)+  - Stream: 1+  - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.4](requirements.md#4.4), [2.7](requirements.md#2.7)++- [x] 15. Implement plan-derived window resolution in the API and drop env window config <!-- id:chkfinj -->+  - Also remove OFFPEAK_START/END from cmd/api/main.go env validation and the handler offpeakStart/offpeakEnd fields+  - Lambda pricing read failure -> 500, never fabricated no-plan (Q14)+  - Blocked-by: chkfini (Write failing tests for plan-derived windows in status/day/history and nullable offpeak)+  - Stream: 1+  - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.4](requirements.md#4.4), [2.7](requirements.md#2.7)++- [x] 16. Write failing tests for bandImports in /day and /history and the shared live-split helper <!-- id:chkfink -->+  - DayEnergy + DaySummary gain nullable bandImports (rated only)+  - Today's split integrated live from readings via ONE shared helper used by both /day and /history (computeTodayEnergy pattern, AC 3.4)+  - /status does NOT carry bandImports (Q29)+  - Blocked-by: chkfind (Implement daily-energy band group and offpeak geometry fields), chkfinj (Implement plan-derived window resolution in the API and drop env window config)+  - Stream: 1+  - Requirements: [3.4](requirements.md#3.4), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7)++- [x] 17. Implement bandImports serving and today's live split <!-- id:chkfinl -->+  - Blocked-by: chkfink (Write failing tests for bandImports in /day and /history and the shared live-split helper)+  - Stream: 1+  - Requirements: [3.4](requirements.md#3.4), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7)++## Poller and tools++- [x] 18. Write failing tests for PlanSource <!-- id:chkfinm -->+  - internal/poller/plansource.go tests+  - Startup load via ListPricing (Scan); last-good cache served on read failure with warn (never treated as no-plan)+  - Cold start with unreachable table retries with backoff (Q14/AC 4.6)+  - Blocked-by: chkfin8 (Implement Segments, Covers, PlanFor, FreeWindow, SegmentBounds), chkfinb (Implement dynamo PricingItem band shape, raw-map legacy detection, transitional read transform)+  - Stream: 2+  - Requirements: [4.6](requirements.md#4.6)++- [x] 19. Implement PlanSource <!-- id:chkfinn -->+  - Blocked-by: chkfinm (Write failing tests for PlanSource)+  - Stream: 2+  - Requirements: [4.6](requirements.md#4.6)++- [x] 20. Write failing tests for the midnight-anchored OffpeakScheduler <!-- id:chkfino -->+  - internal/poller/offpeak.go tests+  - Run loop wakes at local midnight, refreshes PlanSource, resolves that day's window, sleeps to its start (Q27); no-free-band day sleeps to next midnight+  - Offpeak row written with windowStart/windowEnd geometry+  - Readings-only finalisation permitted without pending row or start snapshot when plan load succeeded late (Q36) — snapshot is diagnostics-only since T-1341+  - Blocked-by: chkfinn (Implement PlanSource), chkfind (Implement daily-energy band group and offpeak geometry fields)+  - Stream: 2+  - Requirements: [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.6](requirements.md#4.6)++- [x] 21. Implement OffpeakScheduler rework <!-- id:chkfinp -->+  - Blocked-by: chkfino (Write failing tests for the midnight-anchored OffpeakScheduler)+  - Stream: 2+  - Requirements: [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.6](requirements.md#4.6)++- [x] 22. Write failing tests for summarisation per-outcome gating and rated band block <!-- id:chkfinq -->+  - internal/poller/dailysummary.go tests, per-outcome table from design.md+  - Plan read failure -> PassResultError, no sentinels, retried; plan with free band -> all blocks; plan without free band -> socLow/derived/peak run in whole-day-rated mode, off-peak-split values absent, sentinels set; no plan -> window-independent stats only, band sentinel left unset (terminal until backfill)+  - Band block: rated segments via wall-clock SegmentBounds (fixes latent dayStart.Add DST bug in the peak block); sum raw integrals before per-entry rounding+  - Blocked-by: chkfinn (Implement PlanSource), chkfind (Implement daily-energy band group and offpeak geometry fields)+  - Stream: 2+  - Requirements: [3.5](requirements.md#3.5), [4.4](requirements.md#4.4), [4.6](requirements.md#4.6)++- [x] 23. Implement summarisation rework <!-- id:chkfinr -->+  - Blocked-by: chkfinq (Write failing tests for summarisation per-outcome gating and rated band block)+  - Stream: 2+  - Requirements: [3.5](requirements.md#3.5), [4.4](requirements.md#4.4), [4.6](requirements.md#4.6)++- [x] 24. Write failing tests for backfill CLI plan resolution and band rewrite <!-- id:chkfins -->+  - cmd/backfill-grid + cmd/backfill-solar: per-day plan resolution from the pricing table replaces --offpeak-start/end flags (a backfill spanning the switch date needs per-day windows)+  - backfill-grid additionally rewrites the day's rated bandImports and the offpeak row's window geometry+  - Multi-item writes are non-atomic but idempotent/re-runnable (documented)+  - Blocked-by: chkfinr (Implement summarisation rework)+  - Stream: 2+  - Requirements: [3.5](requirements.md#3.5), [4.5](requirements.md#4.5)++- [x] 25. Implement backfill CLI updates <!-- id:chkfint -->+  - Blocked-by: chkfins (Write failing tests for backfill CLI plan resolution and band rewrite)+  - Stream: 2+  - Requirements: [3.5](requirements.md#3.5), [4.5](requirements.md#4.5)++- [x] 26. Write failing tests for cmd/migrate-pricing <!-- id:chkfinu -->+  - Transform via the shared function from task 7+  - Golden check computes every priced day's costs with the EXACT legacy DayCosts formula (tier-2 table / task 5 helper), aborts on any mismatch before writing+  - Days priced by already-new-shape rows verified band-vs-band and logged+  - Idempotent (rows without peakRate skipped); --dry-run default writes nothing; --apply writes full-item Puts preserving pricingIds and sentinel+  - Blocked-by: chkfinb (Implement dynamo PricingItem band shape, raw-map legacy detection, transitional read transform), chkfin9 (Create shared cross-language cost/segment vectors and Go golden-formula tests)+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4)++- [x] 27. Implement cmd/migrate-pricing <!-- id:chkfinv -->+  - Blocked-by: chkfinu (Write failing tests for cmd/migrate-pricing)+  - Stream: 2+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4)++- [x] 28. Update infrastructure template and remove window config plumbing <!-- id:chkfinw -->+  - infrastructure/template.yaml: ECS TaskRole read-only IAM on PricingTable MUST include dynamodb:Scan (ListPricing is a Scan) + GetItem/Query; TABLE_PRICING env for poller container+  - Drop OffpeakStartParameter/OffpeakEndParameter and OFFPEAK_START/END env from both containers+  - internal/config/config.go: remove OffpeakStart/End fields + validation; cmd/poller/logging.go log line+  - Wiring/config only — no test pair+  - Blocked-by: chkfinj (Implement plan-derived window resolution in the API and drop env window config), chkfinp (Implement OffpeakScheduler rework), chkfinr (Implement summarisation rework)+  - Stream: 2+  - Requirements: [4.1](requirements.md#4.1)++## App: FluxCore and UI++- [x] 29. Write failing FluxCore tests for PricingPlan models and segmentation against shared vectors <!-- id:chkfinx -->+  - FluxCore/Pricing: PricingPlan/PlanWindow/PricingPlanDraft replace PricingPeriod/PricingPeriodDraft+  - covers(date:) uses < endDate (exclusive)+  - Draft validation mirrors server codes; segmentation helper output must match pricing_segments.json vectors exactly+  - Blocked-by: chkfin9 (Create shared cross-language cost/segment vectors and Go golden-formula tests)+  - Stream: 3+  - Requirements: [1.1](requirements.md#1.1), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [2.2](requirements.md#2.2), [6.4](requirements.md#6.4)++- [x] 30. Implement FluxCore plan models and segmentation <!-- id:chkfiny -->+  - Blocked-by: chkfinx (Write failing FluxCore tests for PricingPlan models and segmentation against shared vectors)+  - Stream: 3+  - Requirements: [1.1](requirements.md#1.1), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [2.2](requirements.md#2.2), [6.4](requirements.md#6.4)++- [x] 31. Write failing tests for three-tier cost resolution against cost vectors <!-- id:chkfinz -->+  - DayCosts/PeriodCosts tests against pricing_costs.json+  - Tier 1: bandImports geometry equals rated segments AND free import resolvable from offpeak row with matching geometry; sparse-complete row unusable+  - Tier 2: legacy formula verbatim — server-peak preference, max(0,) clamp, nil-offpeak path; single-rate plans never reach tier 3+  - Tier 3: multi-rate only — eInput x max rate, $0.00 savings+  - feedIn = eOutput x feedInRate, net = import - feedIn in every tier+  - Blocked-by: chkfiny (Implement FluxCore plan models and segmentation)+  - Stream: 3+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [4.5](requirements.md#4.5), [5.2](requirements.md#5.2)++- [x] 32. Implement DayCosts and PeriodCosts three-tier resolution <!-- id:chkfio0 -->+  - Blocked-by: chkfinz (Write failing tests for three-tier cost resolution against cost vectors)+  - Stream: 3+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [4.5](requirements.md#4.5), [5.2](requirements.md#5.2)++- [x] 33. Write failing tests for networking payloads, error codes, and nullable OffpeakData <!-- id:chkfio1 -->+  - URLSessionAPIClient pricing payloads for the band shape; PricingValidationReason new cases incl. legacyShape+  - OffpeakData.windowStart/windowEnd become optional and the offpeak object nullable — nil renders as no window, never defaultWindowStart/End constants (widgets included)+  - Blocked-by: chkfiny (Implement FluxCore plan models and segmentation)+  - Stream: 3+  - Requirements: [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [4.4](requirements.md#4.4)++- [x] 34. Implement networking, APIModels, and chart window changes <!-- id:chkfio2 -->+  - Also DayChartDomain.offpeakRange: replace hardcoded +11h/+14h with the day's window from the API response; no window -> no shading+  - Blocked-by: chkfio1 (Write failing tests for networking payloads, error codes, and nullable OffpeakData)+  - Stream: 3+  - Requirements: [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [4.4](requirements.md#4.4)++- [x] 35. Write failing tests for the pricing editor view model <!-- id:chkfio3 -->+  - PricingViewModel: default rate + exception windows editing (free toggle, rate field per window), 4dp normalisation+  - Succession flow ends the current plan on D and creates the successor starting D (AC 6.3)+  - Overlap remediation copy updated from day-before to switch-day phrasing (AC 6.5)+  - Blocked-by: chkfiny (Implement FluxCore plan models and segmentation)+  - Stream: 3+  - Requirements: [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.5](requirements.md#6.5)++- [x] 36. Implement PricingEditor, plan list summary, and remediation copy <!-- id:chkfio4 -->+  - Settings/Pricing/PricingEditor.swift keeps sheet structure (dates, open-ended toggle, delete, remediation); Windows section rows: start/end pickers + Free toggle + rate field, add/remove+  - PricingPeriodsView band summary e.g. 'Free 10:00-15:00 / $0.2800 01:00-06:00 / $0.3500 default'+  - CostsCard/HistoryPeriodCostsCard layout unchanged (Q21)+  - Blocked-by: chkfio3 (Write failing tests for the pricing editor view model)+  - Stream: 3+  - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.5](requirements.md#6.5)++- [x] 37. Write failing tests for Day Detail and History cost wiring <!-- id:chkfio5 -->+  - DayDetailViewModel/HistoryViewModel pass bandImports + offpeak row data + plan into the cost helper+  - Partial-coverage caption (N of M days priced) retained (AC 3.7)+  - Blocked-by: chkfio0 (Implement DayCosts and PeriodCosts three-tier resolution), chkfio2 (Implement networking, APIModels, and chart window changes)+  - Stream: 3+  - Requirements: [3.7](requirements.md#3.7), [6.1](requirements.md#6.1)++- [x] 38. Implement view-model cost wiring <!-- id:chkfio6 -->+  - Blocked-by: chkfio5 (Write failing tests for Day Detail and History cost wiring)+  - Stream: 3+  - Requirements: [3.7](requirements.md#3.7), [6.1](requirements.md#6.1)++## Cleanup++- [ ] 39. Remove transitional legacy read transform after the migration has run <!-- id:chkfio7 -->+  - Delete the dynamo read-side legacy conversion only; keep the write-path legacyShape rejection permanently+  - Gated by prerequisites.md: the production migration run must have completed and been verified first+  - Blocked-by: chkfinv (Implement cmd/migrate-pricing)+  - Stream: 1+  - Requirements: [5.4](requirements.md#5.4)

Things to double-check

Today's rated region is still integrated twice per request.

livePeakGridImport and liveBandImports cover the same span — the day minus the free window — and each runs IntegrateOffpeakDeltas, which computes five energy channels to use one. Roughly 1.2 MB of scratch allocation per /day request where ~120 KB would do.

The poller deliberately fused these into one integration (dynamo.IntegrateRatedBands, whose doc comment says so); the live path did not follow suit. Not fixed here — now that the boundaries agree it is waste rather than a wrong number, and fusing them is a deliberate design change, not a pre-push tidy. Worth a follow-up ticket. Note the two carry different usability gates (peak is additive-when-usable on the evening window; bands fail wholesale), so today's peak and today's Σ-bands can be present/absent inconsistently in a way past days' cannot.

AC 6.4 is the one cross-language contract with no cross-language pin.

Segmentation and cost resolution are pinned to shared vectors on both sides. PricingPlanDraft.validate mirrors plan.Validate by hand, so the two can drift and AC 6.4 (“a plan accepted locally is not rejected by the server for a rule the client could have checked”) would fail silently. A pricing_validation.json vector set — plan → sorted violated codes — would close it the same way the other two are closed.

A day no plan prices loses dailyUsage and peakPeriods permanently.

The summarisation pass sets derivedStatsComputedAt while skipping Blocks/PeakPeriods. That matches the design table literally — only the band sentinel is left unset — but unlike the band split there is no backfill tool that can recompute the five-block panel afterwards. If a plan is entered late, those days are terminally blank. Left as-is: it is what the design specifies, but worth confirming it is what you want.

make ios-test exits 0 even when a test fails.

PIPE_PRETTY = | xcbeautify masks xcodebuild's exit code with no set -o pipefail, so a red suite reports green. This is how the pre-existing refreshSkipsWhenAlreadyLoading flake goes unnoticed. Pre-existing and outside this branch's scope, so not changed — but it means CI or a scripted gate on this target is not actually gating.

derivedstats.Blocks still uses the elapsed-minutes arithmetic.

internal/derivedstats/blocks.go derives its block boundaries with dayStart.Add(elapsed) — the same latent DST bug class the band capture deliberately retired. Pre-existing, and that package was explicitly unchanged by this feature, so it is out of scope here. It only affects block boundary display on two days a year, but it is the last copy of the pattern.