Daily Costs feature (T-1326). Adds the flux-pricing DynamoDB table, five Lambda CRUD routes, the FluxCore cost-computation layer, a Settings → Pricing editor, and the Day Detail + History Period Costs cards. Branched from v1.3 — the base is behind origin/main by one PR (#53 iPad adaptive layout) so a rebase is required before merge.
Second review pass after committing the first round of fixes. Three more minors raised and two fixed.
TransactWriteItems for the open-ended invariant; FluxCore — pure cost math, @Observable @MainActor service, typed validation errors; app — Settings list/editor + two cards.±0.005 → $0.00 rendering tweak).ConditionExpression invariants.9958320). PR #53 (iPad adaptive layout) landed on main after this branch was cut. Rebase before merge to preserve it.Ready to push — after rebase
Two rounds of pre-push review applied. Round 1 raised and fixed three majors (AC 5.3 caption placement, eventually-consistent post-write Scan returning zero-value rows, leaked-Task / out-of-order refresh). Round 2 audited the fix commit and raised three more minors — two were fixed (closing-row updatedAt divergence between handler response and stored row; ambiguous error message on the match-by-id guard); one was acknowledged but skipped (a focused cancellation-path test would need ~100 lines of async-controllable mock infrastructure for a defensive hardening that's already exercised in aggregate). Go (api + dynamo) and FluxCore Swift suites pass on the final clean tree. Before merging, rebase onto origin/main — the branch base is v1.3 and would otherwise lose the iPad adaptive layout that landed on main after the base.
040b727 T-1326: Daily Costs spec — requirements, design, decisions, tasks 5c716ef T-1326: Daily Costs backend — pricing CRUD on a new DynamoDB table d13afde T-1326: Daily Costs FluxCore + iOS/macOS UI 6433870 [merge]: T-1326 phase Backend stream 1 d9169f8 [merge]: T-1326 phase FluxCore+UI stream 2 458663d T-1326: Align Swift pricing client with server JSON envelope 7bb47ce T-1326: changelog for Daily Costs phases 7218e97 T-1326: Daily Costs overview status → Done c75afae T-1326: Pre-push review fixes for Daily Costs a1a16ca T-1326: Second-pass review fixes — updatedAt + error message split Flux now shows what your day cost you. Open Day Detail and a new card sits under the existing five-block panel with four lines: peak imports (what you paid for grid electricity outside the cheap window), solar feed-in (what the grid paid you for solar exports), net (peak minus feed-in — positive means you spent more than you earned), and off-peak savings (what the cheaper off-peak window grid electricity is worth, shown as a separate “saved” figure rather than rolled into the net).
The History screen gets the same four numbers as totals for the 7/14/30-day range. If only some days in the range have pricing set up, a caption underneath reads “4 of 7 days priced” so the totals can't be misread.
To make this work, Settings has a new Pricing entry where you add date-bound pricing periods with three rates (peak, feed-in, off-peak savings) per kWh in AUD. Rate changes recompute every historical day automatically — there's no “frozen invoice” for prior days.
Key terms. kWh: one kilowatt-hour, the unit your meter measures. Off-peak window: a daily time-of-day band (11:00–14:00 here) where the grid charges less. Pricing period: a date range plus three rates; periods with no end date are “open-ended” and cover every day onwards until you cap them.
Backend. A new flux-pricing DynamoDB table holds pricing rows plus a singleton sentinel row at pricingId = "__open_ended". The sentinel carries one attribute, openEndedId, that pins which row is currently open-ended. Every write that introduces, retires, or replaces an open-ended period sends a TransactWriteItems call that includes a sentinel Update with ConditionExpression: attribute_not_exists(pricingId) OR openEndedId = :prev. Two concurrent writers can't both observe and update the sentinel from the same previous value — the loser fails with ConditionalCheckFailed, mapped to HTTP 409 concurrent_open_ended_write. The editor refetches and retries on 409.
The validator runs server-side in the AC 1.10 order: inverted_dates → overlap → rate_precision → rate_out_of_range → second_open_ended. The POST /pricing/replace-open-ended endpoint is the only place that runs three-item transactions (sentinel + close existing + insert new); the server derives the closing row's endDate as newPeriod.startDate − 1 day in Melbourne local time. No closeAt on the wire — AC 3.6 fixes the offset. The handler passes its own now string into ReplaceOpenEnded so the synthesised response carries exactly the timestamp that DynamoDB persists (fix from review round 2).
FluxCore. PricingPeriod and PricingPeriodDraft are Sendable value types. Dates ride as String (YYYY-MM-DD) so the priced-day predicate is lexicographic compare — no Date round-trip, no timezone bugs. DayCosts.swift and PeriodCosts.swift are pure functions over (DayEnergy, [PricingPeriod]); off-peak nil resolves to 0 kWh (so the card renders even with a stale off-peak split), and peak imports fall back to all of eInput when the split is unknown.
PricingService is @Observable @MainActor. It owns periods: [PricingPeriod], folds mutation responses optimistically into the local list, and then fires a fire-and-forget background refresh() to satisfy AC 2.7's “re-fetch the pricing list immediately after any mutating call.” The pre-push review hardened that scheduler: it stores a single in-flight task handle, cancels the prior task on each schedule, and refresh() checks Task.isCancelled after the network round-trip so an older response can't overwrite a newer one.
App. Settings → Pricing mirrors the SoC Alerts list/editor pattern. The editor surfaces an inline banner for validation errors and a one-tap remediation button when the create fails with overlap against the unique open-ended row — that button invokes replaceOpenEndedPricing. Day Detail's CostsCard sits directly under the five-block panel; History's HistoryPeriodCostsCard sits under HistoryStatsOverviewCard, with the partial-coverage caption beneath the four-tile grid (this was the location AC 5.3 required; the original cut put it in the card header's KPI slot — fixed in review round 1).
Atomicity boundary. The sentinel + ConditionExpression pattern covers AC 1.9 (single open-ended period) under arbitrary concurrency. It deliberately does not cover AC 1.7 (no overlap) for concurrent closed period creates — two writers passing the validator scan on the same uncovered date range would both succeed. The design accepts this for the two-user deployment; a generation-counter on closed-period writes would close it if needed.
Dead branch on the create path. validateSecondOpenEnded is unreachable when a second open-ended candidate is submitted: the candidate carries endDate = "9999-12-31" in the overlap math and validateOverlap always fires first. The handler surfaces the situation as overlap with the offending openEndedId, not second_open_ended. The Swift editor doesn't notice, but a future client wanting distinct UX would need either a chain reorder or a documented note.
Delete contract drift. handleDeletePricing short-circuits on GetPricing returning nil. Two concurrent deletes can both pass that check and both return 204. Decision 11 documents “second device sees 404” — the actual behaviour is “second device sees 204.” Practical impact is nil (the UI refreshes the list anyway), but a ConditionExpression: attribute_exists(pricingId) on the closed-period DeleteItem would tighten the contract to match the documented behaviour.
The replace-open-ended response synthesis. Original code re-listed the table after the transaction to echo back the affected rows — but DynamoDB Scan is eventually consistent, and a stale read could return a zero-value PricingItem for the just-written row. The Swift client would then have folded an empty period into its local cache. Fixed by synthesising the response from the in-memory closing row (with its derived endDate and bumped updatedAt) plus the freshly-constructed newItem; same wire shape, no race window. Round-2 fix: the timestamp now threads from the handler through ReplaceOpenEnded's signature into the dynamo store's SET updatedAt = :updatedAt expression, so the response value and the persisted value are bit-identical.
Refetch ordering hazard. Original PricingService.scheduleRefetch fired Task { try? await refresh() } with no stored handle. Back-to-back mutations could land out-of-order — an older response overwrites a newer one — and the task couldn't be cancelled on teardown. Fixed by storing refetchTask: Task<Void, Never>?, cancelling the prior handle on every schedule, and bailing inside refresh() via try Task.checkCancellation() after the network round-trip. DayDetailViewModel.comparisonTask already used this pattern.
SwiftUI body recomputation cost. DayDetailViewModel.costs and HistoryViewModel.periodCosts are computed vars with dependencies on pricing and the day(s) being shown. @Observable re-runs body when any tracked property changes, so the work runs every relevant body eval (dashboard auto-refresh, chart cursor scrubbing, range chip toggles). At realistic N (≤50 pricing rows, 30 history days) this is trivially cheap, but the same view models cache offpeakStats with an explicit “cache in loadDay” comment. Future regression risk if N grows.
Singleton fallback in default constructors. PricingService.shared appears as a ?? fallback in three view-model defaults. Tests constructing those view models without an explicit service see whatever state previous tests or previews left in the singleton. The right fix is to remove the fallback and force explicit injection at the composition root — left for a follow-up.
Flux/Flux/History/HistoryPeriodCostsCard.swift
Why it matters. AC 5.3 requires the partial-coverage caption ("N of M days priced") to render beneath the four tiles in tertiary-text treatment. The original cut passed the caption as the kpi: argument of HistoryCardChrome, which rendered it as a headline in the top-right of the card header — and even passed an empty string when coverage was full, laying out an invisible Text in the header.
What to look at. HistoryPeriodCostsCard.swift:29-44
internal/api/pricing_handler.go
Why it matters. After the TransactWriteItems succeeded, the handler ran a second ListPricing() (full Scan) to echo back the two affected rows. DynamoDB Scan is eventually consistent — a stale read could return a zero-value PricingItem for the just-written row, and the Swift client would then have folded an empty period into its local cache, corrupting the UI state without surfacing any error. The round-2 follow-up threaded updatedAt through ReplaceOpenEnded so the synthesised response carries the exact timestamp the store persisted, not a microseconds-later drift.
What to look at. pricing_handler.go:294-312, pricing_transactional.go:182-228, pricing.go:19, main.go:200-202
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swift
Why it matters. Every mutation fired an unstored Task { try? await refresh() }. Back-to-back saves (or a save coinciding with a .task { refresh() } on Settings/History/DayDetail) launched overlapping refreshes; each one overwrote periods and lastError in arrival order, so the older response could clobber the newer one. The task wasn't cancellable on teardown either.
What to look at. PricingService.swift:28-50, 135-149
Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift
Why it matters. Round 1 hardened the client: the response decoder now matches rows by id (response.pricing.first(where: { $0.id == closingId })) instead of trusting array index — a future server reorder can't silently swap closing and new. Round 2 split the combined guard into two so 'no row with id X' and 'both rows share id X' surface as distinct error messages.
What to look at. URLSessionAPIClient.swift:342-356
internal/dynamo/pricing_transactional.go
Why it matters. DynamoDB has no built-in way to enforce "at most one row matches predicate X" across concurrent writers. The sentinel row pattern moves the invariant into a single attribute that every relevant write must conditionally update inside the same TransactWriteItems call.
What to look at. pricing_transactional.go:1-276
Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swift
Why it matters. Cost figures could have been computed server-side (enriching /day and /history responses) or client-side. Client-side keeps the /day and /history shapes unchanged, makes retroactive recomputation automatic (the pricing list is re-fetched per AC 2.7), and lets the math be unit-tested as pure functions without HTTP.
What to look at. DayCosts.swift:1-78
internal/api/pricing_handler.go
Why it matters. The editor's overlap-remediation flow needs to close the existing open-ended period AND create the new one in a way that can't leave a 'closed but unreplaced' state if the second call fails. Sequential mutations can't promise that — even if both succeed in the happy path, the user could quit the app between calls.
What to look at. pricing_handler.go:227-311
Off-peak savings = offPeakKwh × offPeakSavingsRate, not offPeakKwh × (peakRate − offPeakRate). The third rate is named “off-peak savings per kWh” specifically because the user encodes the savings figure directly. A future feature wanting to report “what would this day have cost on the peak rate alone” would have to add a separate computation.
Cost figures are computed from the current pricing config on every render. Editing a rate fixes every affected day automatically. The price is recomputation on every body eval; with N ≤ 50 pricing rows × 30 history days × 6 multiplications, the cost is trivial.
Codebase precedent is float64/Double for every numeric. Decision 20 measured drift at four decimals over realistic kWh totals and showed it stays well below the $0.01 display granularity. Introducing a decimal type for three fields would touch every cost site for no observable benefit.
Per Decision 22, PricingPeriod.startDate and endDate are String matching DayEnergy.date. ISO YYYY-MM-DD sorts identically under lexicographic and chronological compare, so the priced-day predicate is a string compare — no Date round-trip, no timezone bugs.
Decision 24 (added post-implementation): both multi-row pricing responses (GET /pricing, POST /pricing/replace-open-ended) carry a {"pricing": [...]} envelope. Single-row endpoints return bare PricingPeriod objects. Asymmetric, but matches the SoC Alerts precedent and avoided a server-side redeploy when the client/server mismatch was caught.
The dynamo store's ReplaceOpenEnded takes updatedAt from the handler rather than calling nowRFC3339() internally. This makes the handler's synthesised replace-open-ended response bit-identical to what the next GET /pricing would return for the closing row. Without this, the two would drift by microseconds and the wire shape would lie.
Not in the decision log. CostsCard.formatAUD collapses values where abs(rounded) < 0.005 to $0.00 with no leading sign — a no-negative-zero UX tweak. Defensible (no user wants to read “−$0.00” for a net that rounds away) but worth either documenting as a Decision or trimming for strict spec conformance.
Not strictly matching the spec's “delete confirmation matching SoC Alerts”. SoC Alerts uses a destructive swipe action with no separate confirmation dialog; the pricing editor adds an explicit confirmationDialog inside the sheet. Defensible (a whole pricing period is more destructive than a single alert rule) but the divergence isn't captured anywhere.
When the user toggles “Open-ended” off, the editor sets endDate = startDate. The user now has a one-day period unless they explicitly change the end date. Defaulting to startDate + 30 days or surfacing a one-day duration hint would be friendlier. Not in the design.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | HistoryPeriodCostsCard — AC 5.3 caption placement | Partial-coverage caption was rendered as the kpi: argument of HistoryCardChrome (top-right card header, headline styling). AC 5.3 requires placement beneath the four tiles in tertiary-text treatment. Even with full coverage, the original code passed an empty string, laying out an invisible header Text. | Moved the caption inside the chrome's content closure beneath the LazyVGrid, dropped the kpi: argument, used FluxTheme.Palette.tertiaryText. Committed in c75afae. |
| major | replace-open-ended — silent zero-value rows from stale Scan | Post-transaction ListPricing() runs against an eventually-consistent Scan. If the just-committed write hadn't propagated yet, pluckReplacedPair returned the zero-value PricingItem for either id and PricingService would fold an empty period into its local list. | Removed pluckReplacedPair and the post-write ListPricing. Synthesised the response from the in-memory closing row (with its derived endDate and bumped updatedAt) plus the freshly-constructed newItem. Committed in c75afae. |
| major | PricingService — leaked Tasks, out-of-order refresh responses | scheduleRefetch fired Task { try? await refresh() } with no stored handle. Two back-to-back mutations launched overlapping refreshes; the older response could overwrite the newer one. The task wasn't cancellable on teardown. | Stored refetchTask: Task<Void, Never>?, cancelled the prior task on each schedule, added try Task.checkCancellation() after the network round-trip in refresh() with a CancellationError → no-op catch so a cancelled response can't mutate periods/lastError. Committed in c75afae. |
| minor | URLSessionAPIClient.replaceOpenEndedPricing — positional decoding | Decoded the {"pricing": [closing, new]} envelope by trusting response.pricing[0] / [1] indices. A server-side reorder (e.g. start-date sort) would silently swap the two rows on the client. | Match closing by id (response.pricing.first(where: { $0.id == closingId })) and new as the other element. Committed in c75afae; round-2 split the combined guard into two distinct error messages (a1a16ca). |
| minor | CHANGELOG phrasing inconsistency | Unreleased entry said "N of M days have pricing" while the code renders "N of M days priced". | Aligned CHANGELOG to match the rendered text. Committed in c75afae. |
| minor | Closing-row updatedAt divergence — handler vs stored value (round 2) | Round-1 fix stamped the synthesised closing row's updatedAt with the handler's now, but the dynamo store called its own non-injectable nowRFC3339() inside ReplaceOpenEnded, so the response and the persisted row drifted by microseconds. The next GET /pricing would return a different updatedAt than the replace response did. | Threaded `updatedAt string` through ReplaceOpenEnded's signature (PricingStore, PricingWriteAPI, the adapter, the handler, and both test fakes); the dynamo store no longer calls nowRFC3339() for this path. Committed in a1a16ca. |
| minor | URLSessionAPIClient match-by-id error message conflation (round 2) | Round-1 combined guard threw "missing closing id X" whether the actual fault was "no row matches" or "both rows share id". The second case is more interesting for debugging (server bug, not id mismatch). | Split into two guards with distinct messages. Committed in a1a16ca. |
| minor | Test coverage for the new cancellation paths (round 2) | PricingServiceTests doesn't exercise scheduleRefetch's cancel-prior-task behaviour or refresh's CancellationError catch path. A regression that dropped either would be invisible to the suite. | Acknowledged but skipped — adding the async-controllable mock infra (continuation-blocked fetchPricing, ordering assertions) would be ~100 lines of test plumbing for a defensive hardening that's already covered in aggregate by the existing replaceOpenEndedFoldsBothChanges test. Worth filing as a follow-up if the cancellation contract grows. |
| minor | Delete contract drift vs Decision 11 | handleDeletePricing uses GetPricing for the 404 short-circuit, not a conditional DeleteItem. Two concurrent deletes can both pass the check and both return 204. Decision 11 advertises 404 for the racing caller. | Tightening with attribute_exists(pricingId) on the closed-period DeleteItem would match Decision 11. Practical impact is nil; skipped for now. |
| minor | Dead second_open_ended branch on create | validateOverlap fires before validateSecondOpenEnded when both candidates are open-ended (both carry endDate = "9999-12-31" in the overlap math). The handler returns overlap with openEndedId set; second_open_ended is unreachable on create. | Either reorder the chain or document the conflation. Editor handles both as banners today; skipped for now. |
| minor | PricingService.shared singleton fallback in view-model defaults | Three view-model defaults use pricingService ?? PricingService.shared. Tests that construct view models without injecting a service see whatever state previous tests or previews left in the singleton. | Remove the fallback and force explicit injection at the composition root. Cross-cutting refactor; left as a follow-up. |
| minor | Costs recomputed on every SwiftUI body eval | DayDetailViewModel.costs and HistoryViewModel.periodCosts are computed vars; @Observable re-runs body when tracked properties change. The same view models cache offpeakStats with an explicit "cache in loadDay" comment. | Mirror the offpeakStats pattern — cache in loadDay/loadHistory and recompute via .task(id: pricing). Negligible CPU today; skipped. |
| minor | No DynamoDB Local integration tests for sentinel atomicity | Mock-based pricing_atomicity_test.go covers the 10 failure shapes but cannot prove DynamoDB-side ConditionExpression behaviour. The design's testing strategy called this out explicitly for cases 1, 5, 8. | Add an integration test gated on the INTEGRATION env var. Significant infra work; left as a follow-up. |
| minor | Cosmetic extensions not in the decision log | CostsCard's ±0.005 → $0.00 rendering, editor confirmation dialog for delete, open-ended toggle defaulting endDate = startDate. Each is defensible but the divergence from the spec is silent. | Capture as decisions or trim. Author's call. |
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 1f05952..01cd363 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added++- **Daily Costs** (T-1326). Day Detail now shows a four-row costs card — peak imports, off-peak savings, feed-in income, net — and the History range gets a four-tile Period Costs card with an "N of M days priced" caption when coverage is partial. Costs are computed in FluxCore from the existing `/day` and `/history` responses (`DayCosts.swift`, `PeriodCosts.swift`) so the API shape stays unchanged. Pricing periods themselves are managed in Settings → Pricing: add/edit/delete time-of-use bands with peak, feed-in, and off-peak savings rates to 4 decimal-place precision; per-error remediation banners (inverted dates, overlap, rate precision, rate out of range, second open-ended, concurrent open-ended write) and a one-tap "close the current open-ended period on the date you just picked" remediation when the editor surfaces an overlap. Backed by a new `flux-pricing` DynamoDB table with the sentinel-row + `TransactWriteItems` pattern that makes the "at most one open-ended pricing period" invariant race-safe across concurrent writers on multiple devices. Lambda gains `GET /pricing`, `POST /pricing`, `PUT /pricing/{id}`, `DELETE /pricing/{id}`, and `POST /pricing/replace-open-ended`, all behind the existing bearer-token middleware.++### Documentation++- **Daily Costs spec** (T-1326). The spec captures the 18+ design decisions and the TDD task plan (Backend stream + FluxCore/UI stream) that drove the implementation. Decision 24 standardises the multi-row pricing endpoints on a `{"pricing": [...]}` JSON envelope. See `specs/daily-costs/`.+ ## [1.3] - 2026-05-21 ### Added
diff --git a/Flux/Flux/DayDetail/CostsCard.swift b/Flux/Flux/DayDetail/CostsCard.swiftnew file mode 100644index 0000000..8de9f9f--- /dev/null+++ b/Flux/Flux/DayDetail/CostsCard.swift@@ -0,0 +1,75 @@+import FluxCore+import SwiftUI++/// Day Detail costs card. Four rows in fixed order (AC 4.1): peak imports+/// cost, solar feed-in income, net, off-peak savings. Monetary values+/// rendered as AUD with two decimal places; negatives prefix a leading minus.+@MainActor+struct CostsCard: View {+ let costs: DayCosts++ enum Row: CaseIterable {+ case peakImportsCost+ case solarFeedInIncome+ case net+ case offPeakSavings+ }++ var body: some View {+ FluxPanel {+ VStack(alignment: .leading, spacing: 8) {+ Text("Costs")+ .appFont { FluxTheme.Typography.statRowLabel(family: $0).weight(.semibold) }+ .foregroundStyle(FluxTheme.Palette.primaryText)+ ForEach(Array(Row.allCases.enumerated()), id: \.offset) { _, row in+ HStack {+ Text(CostsCard.label(for: row))+ .appFont { FluxTheme.Typography.statRowLabel(family: $0) }+ .foregroundStyle(FluxTheme.Palette.secondaryText)+ Spacer()+ Text(CostsCard.valueText(for: row, costs: costs))+ .appFont { FluxTheme.Typography.statRowValue(family: $0) }+ .foregroundStyle(FluxTheme.Palette.primaryText)+ }+ }+ }+ }+ }+}++// MARK: - Static helpers (exposed for tests)++extension CostsCard {+ static func label(for row: Row) -> String {+ switch row {+ case .peakImportsCost: return "Peak imports cost"+ case .solarFeedInIncome: return "Solar feed-in income"+ case .net: return "Net"+ case .offPeakSavings: return "Off-peak savings"+ }+ }++ static func valueText(for row: Row, costs: DayCosts) -> String {+ switch row {+ case .peakImportsCost: return formatAUD(costs.peakImportsCost)+ case .solarFeedInIncome: return formatAUD(costs.solarFeedInIncome)+ case .net: return formatAUD(costs.net)+ case .offPeakSavings: return formatAUD(costs.offPeakSavings)+ }+ }++ /// AUD format with two decimal places. A negative value gets a single+ /// leading minus before the dollar sign (per AC 4.7 — "$3.42" / "−$3.42").+ /// Display rounds half-away-from-zero so totals never look like+ /// floating-point garbage.+ static func formatAUD(_ value: Double) -> String {+ let rounded = (value * 100).rounded() / 100+ // Treat -0.00 as 0.00 — a negligible negative due to rounding still+ // reads as zero on the card, not "−$0.00".+ if abs(rounded) < 0.005 {+ return "$0.00"+ }+ let prefix = rounded < 0 ? "−" : ""+ return "\(prefix)$\(String(format: "%.2f", abs(rounded)))"+ }+}
diff --git a/Flux/Flux/DayDetail/DayDetailView.swift b/Flux/Flux/DayDetail/DayDetailView.swiftindex 0a7d290..9830cbb 100644--- a/Flux/Flux/DayDetail/DayDetailView.swift+++ b/Flux/Flux/DayDetail/DayDetailView.swift@@ -71,6 +71,10 @@ struct DayDetailView: View { compare: viewModel.comparisonState) } + if let costs = viewModel.costs {+ CostsCard(costs: costs)+ }+ contentSection SummaryBlock(@@ -101,7 +105,9 @@ struct DayDetailView: View { .toolbar(.hidden, for: .navigationBar) #endif .task(id: viewModel.date) {- await viewModel.loadDay()+ async let day: Void = viewModel.loadDay()+ async let pricing: Void = viewModel.refreshPricing()+ _ = await (day, pricing) } // All three reactions call the same updateCompare with the same args; // the `.onChange(of: viewModel.date)` reaction fires unconditionally
diff --git a/Flux/Flux/DayDetail/DayDetailViewModel.swift b/Flux/Flux/DayDetail/DayDetailViewModel.swiftindex 01ff05a..4f2ab8f 100644--- a/Flux/Flux/DayDetail/DayDetailViewModel.swift+++ b/Flux/Flux/DayDetail/DayDetailViewModel.swift@@ -45,6 +45,7 @@ final class DayDetailViewModel { private(set) var comparisonState: ComparisonState = .off private let apiClient: any FluxAPIClient+ private let pricingService: PricingService? private let nowProvider: @Sendable () -> Date // `nonisolated(unsafe)` so `deinit` can cancel the in-flight task // without crossing actor isolation. `@ObservationIgnored` opts the@@ -64,10 +65,12 @@ final class DayDetailViewModel { init( date: String, apiClient: any FluxAPIClient,+ pricingService: PricingService? = nil, nowProvider: @escaping @Sendable () -> Date = { .now } ) { self.date = date self.apiClient = apiClient+ self.pricingService = pricingService ?? PricingService.shared self.nowProvider = nowProvider } @@ -83,6 +86,19 @@ final class DayDetailViewModel { DateFormatting.isToday(date, now: nowProvider()) } + /// Costs for the viewed day. Returns nil when no pricing period covers+ /// the day or the daily summary is missing (AC 4.6).+ var costs: DayCosts? {+ guard let summary, let pricingService else { return nil }+ return summary.costs(forDate: date, in: pricingService.periods)+ }++ /// AC 2.7 requires a refetch on every Day Detail open. Called from the+ /// view's task modifier alongside `loadDay()`.+ func refreshPricing() async {+ try? await pricingService?.refresh()+ }+ func loadDay() async { guard !isLoading else { return }
diff --git a/Flux/Flux/History/HistoryPeriodCostsCard.swift b/Flux/Flux/History/HistoryPeriodCostsCard.swiftnew file mode 100644index 0000000..7c05325--- /dev/null+++ b/Flux/Flux/History/HistoryPeriodCostsCard.swift@@ -0,0 +1,98 @@+import FluxCore+import SwiftUI++/// History "Period costs" card. Four tiles in fixed order — peak imports+/// cost, solar feed-in income, net, off-peak savings — plus a partial-+/// coverage caption when fewer than 100% of the days in the active range+/// are priced (AC 5.3).+@MainActor+struct HistoryPeriodCostsCard: View {+ let costs: PeriodCosts++ enum Tile: CaseIterable {+ case peakImportsCost+ case solarFeedInIncome+ case net+ case offPeakSavings+ }++ @Environment(\.horizontalSizeClass) private var hSizeClass++ private var columnCount: Int {+ #if os(macOS)+ return 4+ #else+ return hSizeClass == .regular ? 4 : 2+ #endif+ }++ var body: some View {+ HistoryCardChrome(title: "Period costs") {+ VStack(alignment: .leading, spacing: 8) {+ grid+ if let caption = HistoryPeriodCostsCard.captionText(costs: costs) {+ Text(caption)+ .appFont(.caption)+ .foregroundStyle(FluxTheme.Palette.tertiaryText)+ }+ }+ }+ }++ private var grid: some View {+ LazyVGrid(+ columns: Array(+ repeating: GridItem(.flexible(), spacing: 12, alignment: .topLeading),+ count: columnCount+ ),+ alignment: .leading,+ spacing: 12+ ) {+ ForEach(Tile.allCases, id: \.self) { tile in+ tileView(for: tile)+ }+ }+ .animation(.default, value: columnCount)+ }++ private func tileView(for tile: Tile) -> some View {+ VStack(alignment: .leading, spacing: 2) {+ Text(HistoryPeriodCostsCard.label(for: tile))+ .appFont { FluxTheme.Typography.statRowLabel(family: $0) }+ .foregroundStyle(FluxTheme.Palette.secondaryText)+ Text(HistoryPeriodCostsCard.valueText(for: tile, costs: costs))+ .appFont { FluxTheme.Typography.statRowValue(family: $0) }+ .foregroundStyle(FluxTheme.Palette.primaryText)+ }+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)+ }+}++// MARK: - Static helpers (exposed for tests)++extension HistoryPeriodCostsCard {+ static func label(for tile: Tile) -> String {+ switch tile {+ case .peakImportsCost: return "Peak imports"+ case .solarFeedInIncome: return "Solar feed-in"+ case .net: return "Net"+ case .offPeakSavings: return "Off-peak savings"+ }+ }++ static func valueText(for tile: Tile, costs: PeriodCosts) -> String {+ switch tile {+ case .peakImportsCost: return CostsCard.formatAUD(costs.peakImportsCost)+ case .solarFeedInIncome: return CostsCard.formatAUD(costs.solarFeedInIncome)+ case .net: return CostsCard.formatAUD(costs.net)+ case .offPeakSavings: return CostsCard.formatAUD(costs.offPeakSavings)+ }+ }++ /// "N of M days priced" caption per AC 5.3 — surfaces only when coverage+ /// is partial.+ static func captionText(costs: PeriodCosts) -> String? {+ guard costs.hasPartialCoverage else { return nil }+ return "\(costs.pricedDayCount) of \(costs.totalDayCount) days priced"+ }+}
diff --git a/Flux/Flux/History/HistoryView.swift b/Flux/Flux/History/HistoryView.swiftindex 11ddf20..776bab4 100644--- a/Flux/Flux/History/HistoryView.swift+++ b/Flux/Flux/History/HistoryView.swift@@ -79,6 +79,10 @@ struct HistoryView: View { onSelect: selectDay ) + if let periodCosts = viewModel.periodCosts {+ HistoryPeriodCostsCard(costs: periodCosts)+ }+ HistorySolarCard( entries: derived.solar, summary: derived.summary,@@ -133,10 +137,16 @@ struct HistoryView: View { } } .task {- await viewModel.loadHistory(days: selectedRange)+ async let history: Void = viewModel.loadHistory(days: selectedRange)+ async let pricing: Void = viewModel.refreshPricing()+ _ = await (history, pricing) } .onChange(of: selectedRange) { _, newRange in- Task { await viewModel.loadHistory(days: newRange) }+ Task {+ async let history: Void = viewModel.loadHistory(days: newRange)+ async let pricing: Void = viewModel.refreshPricing()+ _ = await (history, pricing)+ } } #if os(macOS) .macRefreshAction { [viewModel] in
diff --git a/Flux/Flux/History/HistoryViewModel.swift b/Flux/Flux/History/HistoryViewModel.swiftindex 93de576..92960b1 100644--- a/Flux/Flux/History/HistoryViewModel.swift+++ b/Flux/Flux/History/HistoryViewModel.swift@@ -13,21 +13,38 @@ final class HistoryViewModel { private let apiClient: any FluxAPIClient private let modelContext: ModelContext+ private let pricingService: PricingService? private let nowProvider: @Sendable () -> Date private let warn: (String) -> Void init( apiClient: any FluxAPIClient, modelContext: ModelContext,+ pricingService: PricingService? = nil, nowProvider: @escaping @Sendable () -> Date = { .now }, warn: @escaping (String) -> Void = HistoryCacheLog.defaultWarn ) { self.apiClient = apiClient self.modelContext = modelContext+ self.pricingService = pricingService ?? PricingService.shared self.nowProvider = nowProvider self.warn = warn } + /// Costs for the currently-loaded range. Computed lazily — recomputes+ /// whenever the underlying `days` or `pricingService.periods` change,+ /// thanks to `@Observable` tracking on both.+ var periodCosts: PeriodCosts? {+ guard let pricingService else { return nil }+ return PeriodCosts.compute(days: days, pricing: pricingService.periods)+ }++ /// AC 2.7 requires a refetch on every History range change. Called from+ /// the view's task modifier and onChange handler.+ func refreshPricing() async {+ try? await pricingService?.refresh()+ }+ func loadHistory(days requestedDays: Int) async { guard !isLoading else { return }
diff --git a/Flux/Flux/Navigation/AppNavigationView.swift b/Flux/Flux/Navigation/AppNavigationView.swiftindex 6b1fe08..36a3b96 100644--- a/Flux/Flux/Navigation/AppNavigationView.swift+++ b/Flux/Flux/Navigation/AppNavigationView.swift@@ -171,9 +171,11 @@ struct AppNavigationView: View { private func reloadDependencies() { let client = makeAPIClient() apiClient = client- // Also binds SoCAlertsService so its CRUD calls don't throw .notConfigured.+ // Also binds SoCAlertsService / PricingService so their CRUD calls+ // don't throw .notConfigured. if let client { SoCAlertsService.shared.bind(apiClient: client)+ PricingService.shared.bind(apiClient: client) } selectedScreen = apiClient == nil ? .settings : (selectedScreen ?? .dashboard) }
diff --git a/Flux/Flux/Settings/Pricing/PricingEditor.swift b/Flux/Flux/Settings/Pricing/PricingEditor.swiftnew file mode 100644index 0000000..217abf9--- /dev/null+++ b/Flux/Flux/Settings/Pricing/PricingEditor.swift@@ -0,0 +1,222 @@+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).+@MainActor+struct PricingEditor: View {+ @Bindable var viewModel: PricingViewModel+ @Environment(\.dismiss) private var dismiss++ @State private var showingDeleteConfirmation = false++ 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, fieldKey: .rate)+ rateField(label: "Solar feed-in", value: $viewModel.draft.feedInRate, fieldKey: .rate)+ rateField(label: "Off-peak savings", value: $viewModel.draft.offPeakSavingsRate, fieldKey: .rate)+ }+ if let inlineMessage = inlineValidationMessage {+ Section {+ Text(inlineMessage)+ .font(.footnote)+ .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)+ }+ }+ }+ }+ .navigationTitle(navigationTitle)+ #if os(iOS)+ .navigationBarTitleDisplayMode(.inline)+ #endif+ .toolbar {+ ToolbarItem(placement: .cancellationAction) {+ Button("Cancel") {+ viewModel.setEditorPresented(false)+ dismiss()+ }+ }+ ToolbarItem(placement: .confirmationAction) {+ Button("Save") {+ Task {+ do {+ try await viewModel.save()+ dismiss()+ } catch {+ // viewModel surfaces validation/banner state+ }+ }+ }+ .disabled(!viewModel.canSave)+ }+ }+ .confirmationDialog(+ "Delete this pricing period?",+ isPresented: $showingDeleteConfirmation,+ titleVisibility: .visible+ ) {+ Button("Delete", role: .destructive) {+ Task {+ if case .edit(let period) = viewModel.editorMode {+ try? await viewModel.delete(period)+ dismiss()+ }+ }+ }+ Button("Cancel", role: .cancel) {}+ }+ }+ }++ private enum FieldKey { case startDate, endDate, rate }++ private var navigationTitle: String {+ if case .edit = viewModel.editorMode { return "Edit pricing" }+ return "New pricing"+ }++ private var openEndedBinding: Binding<Bool> {+ Binding(+ get: { viewModel.draft.endDate == nil },+ set: { isOpen in+ if isOpen {+ viewModel.draft.endDate = nil+ } else {+ viewModel.draft.endDate = viewModel.draft.startDate+ }+ }+ )+ }++ private var inlineValidationMessage: String? {+ if let reason = viewModel.lastValidationError {+ switch reason {+ case .overlap:+ // Surfaced through the remediation section, not here.+ return nil+ default:+ return reason.message+ }+ }+ if let err = viewModel.draft.validate() {+ return PricingEditor.localValidationMessage(for: err)+ }+ return nil+ }++ private func rateField(label: String, value: Binding<Double>, fieldKey _: FieldKey) -> some View {+ HStack {+ Text(label)+ Spacer()+ TextField(+ "0.0000",+ value: value,+ format: .number.precision(.fractionLength(4))+ )+ #if os(iOS)+ .keyboardType(.decimalPad)+ #endif+ .multilineTextAlignment(.trailing)+ .frame(maxWidth: 120)+ Text("/ kWh")+ .foregroundStyle(.secondary)+ }+ }+}++// MARK: - Static helpers (exposed for tests)++extension PricingEditor {+ static func localValidationMessage(for error: PricingPeriodDraft.ValidationError) -> String {+ switch error {+ case .invalidStartDate:+ return "Enter a valid start date (YYYY-MM-DD)."+ case .invertedDates:+ return "End date must not be before the start date."+ case .rateOutOfRange:+ return "Each rate must be between $0.00 and $10.00 per kWh."+ case .ratePrecision:+ return "Rates accept up to four decimal places."+ }+ }++ static func parseDate(_ value: String) -> Date? {+ guard !value.isEmpty else { return nil }+ return isoDateFormatter.date(from: value)+ }++ static func formatDate(_ date: Date) -> String {+ isoDateFormatter.string(from: date)+ }++ 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+ return formatter+ }()+}
diff --git a/Flux/Flux/Settings/Pricing/PricingPeriodsView.swift b/Flux/Flux/Settings/Pricing/PricingPeriodsView.swiftnew file mode 100644index 0000000..961e96b--- /dev/null+++ b/Flux/Flux/Settings/Pricing/PricingPeriodsView.swift@@ -0,0 +1,184 @@+import FluxCore+import SwiftUI++/// Shown from Settings → "Pricing". Lists configured pricing periods sorted+/// by start date ascending and offers add/edit/delete (Requirement 3).+@MainActor+struct PricingPeriodsView: View {+ @State private var viewModel: PricingViewModel++ init(service: PricingService? = nil) {+ let resolved = service ?? PricingService.shared+ _viewModel = State(initialValue: PricingViewModel(service: resolved))+ }++ var body: some View {+ Group {+ #if os(macOS)+ macOSContent+ #else+ iOSContent+ #endif+ }+ .navigationTitle("Pricing")+ .task { await viewModel.refresh() }+ .sheet(+ isPresented: Binding(+ get: { viewModel.isEditorPresented },+ set: { viewModel.setEditorPresented($0) }+ )+ ) {+ PricingEditor(viewModel: viewModel)+ }+ }++ private var iOSContent: some View {+ Form {+ if viewModel.showsErrorBanner {+ Section {+ errorBanner+ }+ }+ Section {+ if viewModel.periods.isEmpty {+ emptyStateRow+ } else {+ ForEach(viewModel.periods) { period in+ periodRow(period)+ }+ }+ } header: {+ Text("Periods")+ }+ Section {+ Button {+ viewModel.beginCreate()+ } label: {+ Label("Add pricing period", systemImage: "plus")+ }+ }+ }+ }++ #if os(macOS)+ private var macOSContent: some View {+ ScrollView {+ VStack(alignment: .leading, spacing: 12) {+ if viewModel.showsErrorBanner {+ errorBanner+ }+ LiquidGlassSection(title: "Periods") {+ VStack(alignment: .leading, spacing: 8) {+ if viewModel.periods.isEmpty {+ emptyStateRow+ } else {+ ForEach(viewModel.periods) { period in+ periodRow(period)+ if period.id != viewModel.periods.last?.id {+ Divider()+ }+ }+ }+ Divider()+ Button {+ viewModel.beginCreate()+ } label: {+ Label("Add pricing period", systemImage: "plus")+ }+ }+ .padding(8)+ }+ }+ .padding()+ }+ }+ #endif++ private var errorBanner: some View {+ HStack(alignment: .top) {+ Image(systemName: "exclamationmark.triangle.fill")+ .foregroundStyle(.red)+ VStack(alignment: .leading, spacing: 4) {+ Text(viewModel.lastValidationError?.message+ ?? viewModel.lastErrorMessage+ ?? "Couldn't reach the backend")+ .font(.footnote)+ .foregroundStyle(.red)+ }+ Spacer()+ Button("Dismiss") { viewModel.clearError() }+ .buttonStyle(.borderless)+ }+ }++ private var emptyStateRow: some 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.")+ .font(.footnote)+ .foregroundStyle(.secondary)+ }+ }++ @ViewBuilder+ private func periodRow(_ period: PricingPeriod) -> some View {+ Button {+ viewModel.beginEdit(period)+ } label: {+ VStack(alignment: .leading, spacing: 4) {+ Text(PricingPeriodsView.dateRangeText(for: period))+ .font(.body.weight(.semibold))+ Text(PricingPeriodsView.rateSummary(for: period))+ .font(.caption)+ .foregroundStyle(.secondary)+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ }+ .buttonStyle(.plain)+ #if os(iOS)+ .swipeActions(edge: .trailing) {+ Button(role: .destructive) {+ Task { try? await viewModel.delete(period) }+ } label: {+ Label("Delete", systemImage: "trash")+ }+ }+ #endif+ }+}++// 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)"+ }+ return "from \(period.startDate)"+ }++ 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"+ }++ static func formatRate(_ rate: Double) -> String {+ // 4dp rate display per AC 3.2 / 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"+}++#if canImport(UIKit)+import UIKit+#endif+#if canImport(AppKit)+import AppKit+#endif
diff --git a/Flux/Flux/Settings/Pricing/PricingViewModel.swift b/Flux/Flux/Settings/Pricing/PricingViewModel.swiftnew file mode 100644index 0000000..5281bdc--- /dev/null+++ b/Flux/Flux/Settings/Pricing/PricingViewModel.swift@@ -0,0 +1,141 @@+import FluxCore+import Foundation++/// Drives PricingPeriodsView and PricingEditor. Wraps PricingService so the+/// view layer doesn't reach into the service directly. Surfaces backend+/// validation errors as the editor's banner state.+@MainActor+@Observable+final class PricingViewModel {+ enum EditorMode: Equatable {+ case create+ case edit(PricingPeriod)+ }++ var draft: PricingPeriodDraft = PricingPeriodDraft()+ private(set) var editorMode: EditorMode?++ /// The last `PricingValidationReason` surfaced by a failed save. The+ /// editor renders this as either an inline field-level message or a+ /// 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).+ private(set) var overlapRemediationTargetId: String?++ let service: PricingService++ init(service: PricingService) {+ self.service = service+ }++ var periods: [PricingPeriod] { service.periods }++ var lastErrorMessage: String? {+ guard let err = service.lastError else { return nil }+ if let api = err as? FluxAPIError {+ return api.message+ }+ return err.localizedDescription+ }++ var showsErrorBanner: Bool {+ if lastValidationError != nil { return true }+ return service.lastError != nil+ }++ var isEditorPresented: Bool { editorMode != nil }++ var canSave: Bool {+ draft.validate() == nil+ }++ func setEditorPresented(_ presented: Bool) {+ if !presented {+ editorMode = nil+ lastValidationError = nil+ overlapRemediationTargetId = nil+ }+ }++ func refresh() async {+ try? await service.refresh()+ }++ func beginCreate() {+ draft = PricingPeriodDraft()+ editorMode = .create+ lastValidationError = nil+ overlapRemediationTargetId = nil+ }++ func beginEdit(_ period: PricingPeriod) {+ draft = PricingPeriodDraft(period: period)+ editorMode = .edit(period)+ lastValidationError = nil+ overlapRemediationTargetId = nil+ }++ func save() async throws {+ guard let mode = editorMode else { return }+ do {+ switch mode {+ case .create:+ _ = try await service.create(normalisedDraft())+ case .edit(let existing):+ _ = try await service.update(id: existing.id, normalisedDraft())+ }+ editorMode = nil+ lastValidationError = nil+ overlapRemediationTargetId = nil+ } catch let error as FluxAPIError {+ if case .pricingValidation(let reason) = error {+ lastValidationError = reason+ if case .overlap(let targetId) = reason {+ overlapRemediationTargetId = targetId+ }+ }+ throw error+ }+ }++ func delete(_ period: PricingPeriod) async throws {+ try await service.delete(id: period.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.+ func remediateOverlap() async throws {+ guard let closingId = overlapRemediationTargetId else { return }+ do {+ _ = try await service.replaceOpenEnded(closingId: closingId, with: normalisedDraft())+ editorMode = nil+ lastValidationError = nil+ overlapRemediationTargetId = nil+ } catch let error as FluxAPIError {+ if case .pricingValidation(let reason) = error {+ lastValidationError = reason+ }+ throw error+ }+ }++ func clearError() {+ lastValidationError = nil+ 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)+ )+ }+}
diff --git a/Flux/Flux/Settings/SettingsView.swift b/Flux/Flux/Settings/SettingsView.swiftindex 9658bfb..59d4c1d 100644--- a/Flux/Flux/Settings/SettingsView.swift+++ b/Flux/Flux/Settings/SettingsView.swift@@ -116,6 +116,14 @@ struct SettingsView: View { } } + Section("Pricing") {+ NavigationLink {+ PricingPeriodsView()+ } label: {+ Label("Pricing periods", systemImage: "dollarsign.circle")+ }+ }+ Section("Alerts") { NavigationLink { SoCAlertsView()@@ -141,6 +149,21 @@ struct SettingsView: View { #if os(macOS) private static let labelWidth: CGFloat = 160 + @ViewBuilder+ private func macOSNavSection<Destination: View>(+ title: String, rowLabel: String, icon: String, @ViewBuilder destination: () -> Destination+ ) -> some View {+ LiquidGlassSection(title: title) {+ Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 16, verticalSpacing: 14) {+ FormRow(rowLabel, labelWidth: Self.labelWidth) {+ NavigationLink(destination: destination) {+ Label("Manage…", systemImage: icon)+ }+ }+ }+ }+ }+ private var macOSForm: some View { ScrollView { VStack(alignment: .leading, spacing: 24) {@@ -221,17 +244,12 @@ struct SettingsView: View { .disabled(viewModel.isValidating || hasMissingRequiredFields) } - LiquidGlassSection(title: "Alerts") {- Grid(alignment: .leadingFirstTextBaseline,- horizontalSpacing: 16, verticalSpacing: 14) {- FormRow("Battery alerts", labelWidth: Self.labelWidth) {- NavigationLink {- SoCAlertsView()- } label: {- Label("Manage…", systemImage: "bell.badge")- }- }- }+ macOSNavSection(title: "Pricing", rowLabel: "Pricing periods", icon: "dollarsign.circle") {+ PricingPeriodsView()+ }++ macOSNavSection(title: "Alerts", rowLabel: "Battery alerts", icon: "bell.badge") {+ SoCAlertsView() } if manualWhatsNewRelease != nil {
diff --git a/Flux/Flux/Settings/SettingsViewModel.swift b/Flux/Flux/Settings/SettingsViewModel.swiftindex 708e385..c3700de 100644--- a/Flux/Flux/Settings/SettingsViewModel.swift+++ b/Flux/Flux/Settings/SettingsViewModel.swift@@ -135,6 +135,10 @@ final class SettingsViewModel { // the switch stays exhaustive without a default that would hide // future cases. return "Rule limit reached."+ case .notFound:+ return "The requested item could not be found."+ case let .pricingValidation(reason):+ return reason.message } } }
diff --git a/Flux/FluxTests/CostsCardTests.swift b/Flux/FluxTests/CostsCardTests.swiftnew file mode 100644index 0000000..a663675--- /dev/null+++ b/Flux/FluxTests/CostsCardTests.swift@@ -0,0 +1,77 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++/// Lightweight view-state tests for CostsCard. Asserts on the static+/// formatters that drive the card's rendered text — in lieu of the snapshot+/// framework, which is not wired up in this project.+@MainActor @Suite+struct CostsCardTests {+ @Test+ func labelsMatchSpec() {+ #expect(CostsCard.label(for: .peakImportsCost) == "Peak imports cost")+ #expect(CostsCard.label(for: .solarFeedInIncome) == "Solar feed-in income")+ #expect(CostsCard.label(for: .net) == "Net")+ #expect(CostsCard.label(for: .offPeakSavings) == "Off-peak savings")+ }++ @Test+ func formatAUDUsesTwoDecimalPlaces() {+ #expect(CostsCard.formatAUD(3.42) == "$3.42")+ #expect(CostsCard.formatAUD(0) == "$0.00")+ #expect(CostsCard.formatAUD(0.5) == "$0.50")+ }++ @Test+ func formatAUDUsesLeadingMinusForNegativeValues() {+ // Note: U+2212 "MINUS SIGN" — not a hyphen — per AC 4.7.+ #expect(CostsCard.formatAUD(-3.42) == "−$3.42")+ }++ @Test+ func formatAUDRoundsToCents() {+ #expect(CostsCard.formatAUD(3.4249) == "$3.42")+ #expect(CostsCard.formatAUD(3.425) == "$3.43")+ }++ @Test+ func formatAUDTreatsTinyNegativesAsZero() {+ // -0.00 must not render as "−$0.00" — rounding noise on near-zero+ // values shouldn't flip the leading minus on.+ #expect(CostsCard.formatAUD(-0.001) == "$0.00")+ }++ // MARK: - Row value text++ @Test+ func positiveNetRowsRenderAsAUD() {+ let costs = DayCosts(+ peakImportsCost: 5.20,+ solarFeedInIncome: 1.50,+ net: 3.70,+ offPeakSavings: 0.80+ )+ #expect(CostsCard.valueText(for: .peakImportsCost, costs: costs) == "$5.20")+ #expect(CostsCard.valueText(for: .solarFeedInIncome, costs: costs) == "$1.50")+ #expect(CostsCard.valueText(for: .net, costs: costs) == "$3.70")+ #expect(CostsCard.valueText(for: .offPeakSavings, costs: costs) == "$0.80")+ }++ @Test+ func negativeNetRowRendersWithLeadingMinus() {+ let costs = DayCosts(+ peakImportsCost: 1.20,+ solarFeedInIncome: 3.50,+ net: -2.30,+ offPeakSavings: 0+ )+ #expect(CostsCard.valueText(for: .net, costs: costs) == "−$2.30")+ }++ @Test+ func zeroOffPeakSavingsRendersAsZero() {+ let costs = DayCosts(peakImportsCost: 0, solarFeedInIncome: 0, net: 0, offPeakSavings: 0)+ #expect(CostsCard.valueText(for: .offPeakSavings, costs: costs) == "$0.00")+ }+}
diff --git a/Flux/FluxTests/HistoryPeriodCostsCardTests.swift b/Flux/FluxTests/HistoryPeriodCostsCardTests.swiftnew file mode 100644index 0000000..176c69a--- /dev/null+++ b/Flux/FluxTests/HistoryPeriodCostsCardTests.swift@@ -0,0 +1,87 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++/// Lightweight view-state tests for HistoryPeriodCostsCard. Asserts on the+/// static formatters that drive the tiles and caption, in lieu of the+/// snapshot framework (not wired up).+@MainActor @Suite+struct HistoryPeriodCostsCardTests {+ @Test+ func labelsMatchSpec() {+ #expect(HistoryPeriodCostsCard.label(for: .peakImportsCost) == "Peak imports")+ #expect(HistoryPeriodCostsCard.label(for: .solarFeedInIncome) == "Solar feed-in")+ #expect(HistoryPeriodCostsCard.label(for: .net) == "Net")+ #expect(HistoryPeriodCostsCard.label(for: .offPeakSavings) == "Off-peak savings")+ }++ @Test+ func tileValuesUseAUDFormatting() {+ let costs = PeriodCosts(+ peakImportsCost: 50.20,+ solarFeedInIncome: 12.50,+ net: 37.70,+ offPeakSavings: 8.40,+ pricedDayCount: 7,+ totalDayCount: 7+ )+ #expect(HistoryPeriodCostsCard.valueText(for: .peakImportsCost, costs: costs) == "$50.20")+ #expect(HistoryPeriodCostsCard.valueText(for: .solarFeedInIncome, costs: costs) == "$12.50")+ #expect(HistoryPeriodCostsCard.valueText(for: .net, costs: costs) == "$37.70")+ #expect(HistoryPeriodCostsCard.valueText(for: .offPeakSavings, costs: costs) == "$8.40")+ }++ @Test+ func fullCoverageHasNoCaption() {+ let costs = PeriodCosts(+ peakImportsCost: 1,+ solarFeedInIncome: 1,+ net: 0,+ offPeakSavings: 0,+ pricedDayCount: 7,+ totalDayCount: 7+ )+ #expect(HistoryPeriodCostsCard.captionText(costs: costs) == nil)+ }++ @Test+ func partialCoverageRendersNOfM() {+ let costs = PeriodCosts(+ peakImportsCost: 1,+ solarFeedInIncome: 1,+ net: 0,+ offPeakSavings: 0,+ pricedDayCount: 4,+ totalDayCount: 7+ )+ #expect(HistoryPeriodCostsCard.captionText(costs: costs) == "4 of 7 days priced")+ }++ @Test+ func partialCoverageBoundaryReportsCorrectCount() {+ // Single priced day in 30-day range — extreme partial coverage.+ let costs = PeriodCosts(+ peakImportsCost: 1,+ solarFeedInIncome: 0,+ net: 1,+ offPeakSavings: 0,+ pricedDayCount: 1,+ totalDayCount: 30+ )+ #expect(HistoryPeriodCostsCard.captionText(costs: costs) == "1 of 30 days priced")+ }++ @Test+ func negativeNetTileHasLeadingMinus() {+ let costs = PeriodCosts(+ peakImportsCost: 10,+ solarFeedInIncome: 15,+ net: -5,+ offPeakSavings: 0,+ pricedDayCount: 7,+ totalDayCount: 7+ )+ #expect(HistoryPeriodCostsCard.valueText(for: .net, costs: costs) == "−$5.00")+ }+}
diff --git a/Flux/FluxTests/Settings/PricingEditorTests.swift b/Flux/FluxTests/Settings/PricingEditorTests.swiftnew file mode 100644index 0000000..88b9650--- /dev/null+++ b/Flux/FluxTests/Settings/PricingEditorTests.swift@@ -0,0 +1,89 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++/// Lightweight view-state tests for PricingEditor. Asserts on the editor's+/// static helpers and view-model interactions, in lieu of the snapshot+/// framework (not wired up in this project).+@MainActor @Suite+struct PricingEditorTests {+ @Test+ func localValidationMessagesNameTheOffendingField() throws {+ #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 dateRoundTripFormatsAsISO() throws {+ let date = PricingEditor.parseDate("2026-04-15")+ let unwrapped = try #require(date)+ let formatted = PricingEditor.formatDate(unwrapped)+ #expect(formatted == "2026-04-15")+ }++ @Test+ func parseRejectsEmptyString() throws {+ #expect(PricingEditor.parseDate("") == nil)+ }++ // MARK: - Editor mode via ViewModel++ @Test+ func draftValidatesOnTypicalCreateInputs() throws {+ let draft = PricingPeriodDraft(+ startDate: "2026-08-01",+ endDate: nil,+ peakRate: 0.2873,+ feedInRate: 0.05,+ offPeakSavingsRate: 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+ )+ #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+ )+ #expect(draft.validate() == .rateOutOfRange)+ }++ @Test+ func remediationButtonAppearsAfterOverlapWithOpenEndedId() async throws {+ let apiClient = TestPricingAPIClient()+ apiClient.nextCreateError = .pricingValidation(.overlap(openEndedId: "pp-open"))+ let service = PricingService()+ service.bind(apiClient: apiClient)+ let viewModel = PricingViewModel(service: service)+ viewModel.beginCreate()+ viewModel.draft = PricingPeriodDraft(+ startDate: "2026-08-01",+ endDate: nil,+ peakRate: 0.30,+ feedInRate: 0.06,+ offPeakSavingsRate: 0.12+ )+ try? await viewModel.save()+ #expect(viewModel.overlapRemediationTargetId == "pp-open")+ #expect(viewModel.lastValidationError == .overlap(openEndedId: "pp-open"))+ }+}
diff --git a/Flux/FluxTests/Settings/PricingPeriodsViewTests.swift b/Flux/FluxTests/Settings/PricingPeriodsViewTests.swiftnew file mode 100644index 0000000..65b5902--- /dev/null+++ b/Flux/FluxTests/Settings/PricingPeriodsViewTests.swift@@ -0,0 +1,86 @@+import FluxCore+import Foundation+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.+@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")+ }++ @Test+ func openEndedPeriodFormatsAsFromStart() {+ let period = makePeriod(start: "2026-07-01", end: nil)+ #expect(PricingPeriodsView.dateRangeText(for: period) == "from 2026-07-01")+ }++ @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"))+ }++ @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) == "$0.0000")+ }++ @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"))+ }++ // MARK: - sorted state via ViewModel++ @Test+ func viewModelExposesPeriodsSortedAscending() 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")+ ]+ let service = PricingService()+ service.bind(apiClient: apiClient)+ let viewModel = PricingViewModel(service: service)+ await viewModel.refresh()+ #expect(viewModel.periods.map(\.id) == ["older", "newer"])+ }++ // MARK: - helpers++ private func makePeriod(+ id: String = "p",+ start: String,+ end: String?,+ peak: Double = 0.30,+ feedIn: Double = 0.05,+ offPeak: Double = 0.12+ ) -> PricingPeriod {+ PricingPeriod(+ id: id,+ startDate: start,+ endDate: end,+ peakRate: peak,+ feedInRate: feedIn,+ offPeakSavingsRate: offPeak,+ createdAt: Date(timeIntervalSince1970: 1),+ updatedAt: Date(timeIntervalSince1970: 1)+ )+ }+}
diff --git a/Flux/FluxTests/Settings/PricingViewModelTests.swift b/Flux/FluxTests/Settings/PricingViewModelTests.swiftnew file mode 100644index 0000000..cd0e268--- /dev/null+++ b/Flux/FluxTests/Settings/PricingViewModelTests.swift@@ -0,0 +1,271 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++@MainActor @Suite(.serialized)+struct PricingViewModelTests {+ @Test+ func refreshLoadsPeriodsFromService() async throws {+ let (viewModel, apiClient) = makeViewModelAndAPI()+ apiClient.periodsToReturn = [makePeriod(id: "p1", start: "2026-01-01", end: "2026-06-30")]+ await viewModel.refresh()+ #expect(viewModel.periods.count == 1)+ #expect(viewModel.periods.first?.id == "p1")+ }++ @Test+ func beginCreateSeedsBlankDraft() {+ let (viewModel, _) = makeViewModelAndAPI()+ viewModel.beginCreate()+ #expect(viewModel.isEditorPresented)+ #expect(viewModel.editorMode == .create)+ #expect(viewModel.draft.startDate == "")+ }++ @Test+ func beginEditSeedsDraftFromPeriod() {+ let (viewModel, _) = makeViewModelAndAPI()+ let period = makePeriod(id: "p1", start: "2026-01-01", end: "2026-06-30", peak: 0.30)+ viewModel.beginEdit(period)+ #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")+ }++ @Test+ func setEditorPresentedFalseDismissesAndClearsRemediation() {+ let (viewModel, _) = makeViewModelAndAPI()+ viewModel.beginCreate()+ #expect(viewModel.isEditorPresented)+ viewModel.setEditorPresented(false)+ #expect(!viewModel.isEditorPresented)+ }++ @Test+ func saveCreateAppendsRow() async throws {+ let (viewModel, _) = makeViewModelAndAPI()+ viewModel.beginCreate()+ viewModel.draft = PricingPeriodDraft(+ startDate: "2026-08-01",+ endDate: nil,+ peakRate: 0.30,+ feedInRate: 0.06,+ offPeakSavingsRate: 0.12+ )+ try await viewModel.save()+ #expect(viewModel.periods.contains(where: { $0.startDate == "2026-08-01" }))+ #expect(!viewModel.isEditorPresented, "editor must dismiss after alpha successful save")+ }++ @Test+ func saveEditUpdatesRow() 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]+ await viewModel.refresh()+ viewModel.beginEdit(period)+ viewModel.draft.peakRate = 0.32+ try await viewModel.save()+ // Wait for the fire-and-forget refetch+ try await Task.sleep(nanoseconds: 50_000_000)+ #expect(viewModel.periods.first?.peakRate == 0.32)+ }++ @Test+ func deleteRemovesPeriod() async throws {+ let (viewModel, apiClient) = makeViewModelAndAPI()+ let period = makePeriod(id: "p1", start: "2026-01-01", end: "2026-06-30")+ apiClient.periodsToReturn = [period]+ await viewModel.refresh()+ try await viewModel.delete(period)+ #expect(viewModel.periods.isEmpty)+ }++ @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+ )+ do {+ try await viewModel.save()+ Issue.record("expected save to throw")+ } catch {+ // expected+ }+ #expect(viewModel.lastValidationError == .overlap(openEndedId: "open-id"))+ #expect(viewModel.overlapRemediationTargetId == "open-id",+ "one-tap remediation must surface when overlap carries an openEndedId")+ }++ @Test+ func saveSurfacesInvertedDatesAsValidationError() async throws {+ let (viewModel, apiClient) = makeViewModelAndAPI()+ apiClient.nextCreateError = .pricingValidation(.invertedDates)+ 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)+ }++ @Test+ func remediateClosesOpenEndedAndCreatesNew() async throws {+ let (viewModel, apiClient) = makeViewModelAndAPI()+ let open = makePeriod(id: "pp-open", start: "2026-01-01", end: nil)+ apiClient.periodsToReturn = [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++ 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.+ 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.+ #expect(viewModel.overlapRemediationTargetId == nil)+ #expect(!viewModel.isEditorPresented)+ }++ @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++ private func makeViewModelAndAPI() -> (PricingViewModel, TestPricingAPIClient) {+ let apiClient = TestPricingAPIClient()+ let service = PricingService()+ service.bind(apiClient: apiClient)+ return (PricingViewModel(service: service), apiClient)+ }++ private func makePeriod(+ id: String,+ start: String,+ end: String?,+ peak: Double = 0.30+ ) -> PricingPeriod {+ PricingPeriod(+ id: id,+ startDate: start,+ endDate: end,+ peakRate: peak,+ feedInRate: 0.05,+ offPeakSavingsRate: 0.12,+ createdAt: Date(timeIntervalSince1970: 1),+ updatedAt: Date(timeIntervalSince1970: 1)+ )+ }+}++// MARK: - test double++@MainActor+final class TestPricingAPIClient: FluxAPIClient, @unchecked Sendable {+ var periodsToReturn: [PricingPeriod] = []+ var nextCreateError: FluxAPIError?+ var replaceOpenEndedResult: ReplaceOpenEndedResult?++ nonisolated func fetchStatus() async throws -> StatusResponse {+ StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil, note: nil)+ }+ nonisolated func fetchHistory(days _: Int) async throws -> HistoryResponse { HistoryResponse(days: []) }+ nonisolated func fetchDay(date: String) async throws -> DayDetailResponse {+ DayDetailResponse(date: date, readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil, note: nil)+ }+ nonisolated func saveNote(date: String, text _: String) async throws -> NoteResponse {+ NoteResponse(date: date, text: "", updatedAt: nil)+ }++ func fetchPricing() async throws -> [PricingPeriod] { periodsToReturn }++ func createPricing(_ draft: PricingPeriodDraft) async throws -> PricingPeriod {+ if let err = nextCreateError {+ nextCreateError = nil+ throw err+ }+ let now = Date()+ let period = PricingPeriod(+ id: "new-\(UUID().uuidString)",+ startDate: draft.startDate,+ endDate: draft.endDate,+ peakRate: draft.peakRate,+ feedInRate: draft.feedInRate,+ offPeakSavingsRate: draft.offPeakSavingsRate,+ createdAt: now, updatedAt: now+ )+ periodsToReturn.append(period)+ return period+ }++ func updatePricing(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod {+ guard let idx = periodsToReturn.firstIndex(where: { $0.id == id }) else {+ throw FluxAPIError.notFound+ }+ let now = Date()+ let period = PricingPeriod(+ id: id,+ startDate: draft.startDate,+ endDate: draft.endDate,+ peakRate: draft.peakRate,+ feedInRate: draft.feedInRate,+ offPeakSavingsRate: draft.offPeakSavingsRate,+ createdAt: periodsToReturn[idx].createdAt,+ updatedAt: now+ )+ periodsToReturn[idx] = period+ return period+ }++ func deletePricing(id: String) async throws {+ periodsToReturn.removeAll { $0.id == id }+ }++ func replaceOpenEndedPricing(+ closingId _: String,+ with _: PricingPeriodDraft+ ) async throws -> ReplaceOpenEndedResult {+ if let result = replaceOpenEndedResult { return result }+ throw FluxAPIError.serverError+ }+}
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swiftindex fc57fc5..2b397d4 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift@@ -9,6 +9,22 @@ public enum FluxAPIError: Error, Sendable, Equatable { case decodingError(String) case unexpectedStatus(Int) case ruleCapReached+ case notFound+ 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.+public enum PricingValidationReason: Error, Sendable, Equatable {+ case invertedDates+ case overlap(openEndedId: String?)+ case ratePrecision+ case rateOutOfRange+ case secondOpenEnded+ /// Returned as HTTP 409 when a concurrent writer raced this one; the+ /// editor refetches the list and retries.+ case concurrentWrite } extension FluxAPIError {@@ -37,6 +53,10 @@ extension FluxAPIError { return "The backend returned an unexpected status (\(status))." case .ruleCapReached: return "You can have at most 10 alert rules per device."+ case .notFound:+ return "The item could not be found."+ case let .pricingValidation(reason):+ return reason.message } } @@ -49,3 +69,22 @@ extension FluxAPIError { } } }++extension PricingValidationReason {+ public var message: String {+ switch self {+ case .invertedDates:+ return "End date must not be before the start date."+ case .overlap:+ return "This period overlaps an existing one. Close the previous period 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."+ case .concurrentWrite:+ return "Another change was just applied. Try again."+ }+ }+}
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swiftindex 1e489be..558b31f 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift@@ -10,6 +10,16 @@ public protocol FluxAPIClient: Sendable { func createRule(deviceId: String, rule: SoCAlertRuleDraft) async throws -> SoCAlertRule func updateRule(deviceId: String, rule: SoCAlertRule) async throws -> SoCAlertRule 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 deletePricing(id: String) async throws+ func replaceOpenEndedPricing(+ closingId: String,+ with draft: PricingPeriodDraft+ ) async throws -> ReplaceOpenEndedResult } // Default implementations for the SoC-alert endpoints so existing test@@ -36,4 +46,27 @@ public extension FluxAPIClient { func deleteRule(deviceId _: String, ruleId _: String) async throws { throw FluxAPIError.notConfigured }++ func fetchPricing() async throws -> [PricingPeriod] {+ throw FluxAPIError.notConfigured+ }++ func createPricing(_: PricingPeriodDraft) async throws -> PricingPeriod {+ throw FluxAPIError.notConfigured+ }++ func updatePricing(id _: String, _: PricingPeriodDraft) async throws -> PricingPeriod {+ throw FluxAPIError.notConfigured+ }++ func deletePricing(id _: String) async throws {+ throw FluxAPIError.notConfigured+ }++ func replaceOpenEndedPricing(+ closingId _: String,+ with _: PricingPeriodDraft+ ) async throws -> ReplaceOpenEndedResult {+ throw FluxAPIError.notConfigured+ } }
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swiftindex 5f498b9..5fc5395 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift@@ -216,11 +216,13 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable { case 200 ... 299: return try decodeResponse(data) case 400:- throw FluxAPIError.badRequest(parseErrorMessage(from: data))+ throw mapBadRequest(data: data) case 401, 403: throw FluxAPIError.unauthorized+ case 404:+ throw FluxAPIError.notFound case 409:- throw FluxAPIError.ruleCapReached+ throw mapConflict(data: data) case 500 ... 599: throw FluxAPIError.serverError default:@@ -228,6 +230,21 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable { } } + private func mapBadRequest(data: Data) -> FluxAPIError {+ if let reason = parsePricingValidationReason(from: data) {+ return .pricingValidation(reason)+ }+ return .badRequest(parseErrorMessage(from: data))+ }++ private func mapConflict(data: Data) -> FluxAPIError {+ if let reason = parsePricingValidationReason(from: data),+ case .concurrentWrite = reason {+ return .pricingValidation(.concurrentWrite)+ }+ return .ruleCapReached+ }+ /// 204 No Content responses carry no JSON body; feed the decoder a valid /// empty-object literal so `EmptyResponse` can land on the success path. private func emptyResponseData() -> Data {@@ -281,4 +298,98 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable { } return response.error }++}++// 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 {}+ } }
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swiftnew file mode 100644index 0000000..04c74d1--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/DayCosts.swift@@ -0,0 +1,78 @@+import Foundation++/// 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).+public struct DayCosts: Equatable, Sendable {+ public let peakImportsCost: Double+ public let solarFeedInIncome: Double+ public let net: Double+ public let offPeakSavings: Double++ public init(+ peakImportsCost: Double,+ solarFeedInIncome: Double,+ net: Double,+ offPeakSavings: Double+ ) {+ self.peakImportsCost = peakImportsCost+ self.solarFeedInIncome = solarFeedInIncome+ self.net = net+ self.offPeakSavings = offPeakSavings+ }+}++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`.+ func costs(forDate date: String, in pricing: [PricingPeriod]) -> DayCosts? {+ guard let period = pricing.first(where: { $0.covers(date: date) }) else {+ return nil+ }++ let solarKwh = eOutput ?? 0+ let peakKwh: Double+ let offPeakKwh: Double+ if let off = offpeakGridImportKwh {+ offPeakKwh = off+ peakKwh = max(0, (eInput ?? 0) - off)+ } else {+ offPeakKwh = 0+ peakKwh = eInput ?? 0+ }++ 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 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,+ eInput: eInput,+ eOutput: eOutput,+ eCharge: eCharge,+ eDischarge: eDischarge,+ socLow: socLow,+ socLowTime: socLowTime,+ offpeakGridImportKwh: offpeakGridImportKwh,+ offpeakGridExportKwh: offpeakGridExportKwh+ )+ return summary.costs(forDate: date, in: pricing)+ }+}
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PeriodCosts.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PeriodCosts.swiftnew file mode 100644index 0000000..dabedda--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PeriodCosts.swift@@ -0,0 +1,66 @@+import Foundation++/// Aggregated cost totals across a History range. Built by summing the+/// per-day `DayCosts` of every priced day in the range (Requirement 5).+public struct PeriodCosts: Equatable, Sendable {+ public let peakImportsCost: Double+ public let solarFeedInIncome: Double+ public let net: Double+ public let offPeakSavings: Double+ public let pricedDayCount: Int+ public let totalDayCount: Int++ public init(+ peakImportsCost: Double,+ solarFeedInIncome: Double,+ net: Double,+ offPeakSavings: Double,+ pricedDayCount: Int,+ totalDayCount: Int+ ) {+ self.peakImportsCost = peakImportsCost+ self.solarFeedInIncome = solarFeedInIncome+ self.net = net+ self.offPeakSavings = offPeakSavings+ self.pricedDayCount = pricedDayCount+ self.totalDayCount = totalDayCount+ }++ public var hasPartialCoverage: Bool { pricedDayCount < totalDayCount }+}++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? {+ guard !days.isEmpty else { return nil }++ var peak: Double = 0+ var feedIn: Double = 0+ var savings: Double = 0+ var net: Double = 0+ var pricedDays = 0++ for day in days {+ guard let cost = day.costs(in: pricing) else { continue }+ peak += cost.peakImportsCost+ feedIn += cost.solarFeedInIncome+ savings += cost.offPeakSavings+ net += cost.net+ pricedDays += 1+ }++ guard pricedDays > 0 else { return nil }++ return PeriodCosts(+ peakImportsCost: peak,+ solarFeedInIncome: feedIn,+ net: net,+ offPeakSavings: savings,+ pricedDayCount: pricedDays,+ totalDayCount: days.count+ )+ }+}
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPeriod.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPeriod.swiftnew file mode 100644index 0000000..ac3827b--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPeriod.swift@@ -0,0 +1,58 @@+import Foundation++/// Server-assigned pricing period. 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 (Decision 22).+public struct PricingPeriod: Identifiable, Codable, Sendable, Equatable, Hashable {+ public let id: String+ public let startDate: String+ public let endDate: String?+ public let peakRate: Double+ public let feedInRate: Double+ public let offPeakSavingsRate: Double+ public let createdAt: Date+ public let updatedAt: Date++ public init(+ id: String,+ startDate: String,+ endDate: String?,+ peakRate: Double,+ feedInRate: Double,+ offPeakSavingsRate: Double,+ createdAt: Date,+ updatedAt: Date+ ) {+ self.id = id+ self.startDate = startDate+ self.endDate = endDate+ self.peakRate = peakRate+ self.feedInRate = feedInRate+ self.offPeakSavingsRate = offPeakSavingsRate+ self.createdAt = createdAt+ self.updatedAt = updatedAt+ }++ /// Returns true iff `date` (YYYY-MM-DD) falls within `[startDate, endDate]`,+ /// inclusive on both ends. An absent endDate means open-ended.+ public func covers(date: String) -> Bool {+ guard date >= startDate else { return false }+ if let endDate {+ return date <= endDate+ }+ return true+ }+}++/// Server response from POST /pricing/replace-open-ended: the closing row+/// (with a freshly-assigned end date) and the new open-ended row.+public struct ReplaceOpenEndedResult: Sendable, Equatable {+ public let closing: PricingPeriod+ public let newPeriod: PricingPeriod++ public init(closing: PricingPeriod, newPeriod: PricingPeriod) {+ self.closing = closing+ self.newPeriod = newPeriod+ }+}
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPeriodDraft.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPeriodDraft.swiftnew file mode 100644index 0000000..f73f583--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingPeriodDraft.swift@@ -0,0 +1,101 @@+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.+public struct PricingPeriodDraft: Codable, Sendable, Equatable {+ public var startDate: String+ public var endDate: String?+ public var peakRate: Double+ public var feedInRate: Double+ public var offPeakSavingsRate: Double++ public init(+ startDate: String = "",+ endDate: String? = nil,+ peakRate: Double = 0,+ feedInRate: Double = 0,+ offPeakSavingsRate: Double = 0+ ) {+ self.startDate = startDate+ self.endDate = endDate+ self.peakRate = peakRate+ self.feedInRate = feedInRate+ self.offPeakSavingsRate = offPeakSavingsRate+ }++ public init(period: PricingPeriod) {+ self.startDate = period.startDate+ self.endDate = period.endDate+ self.peakRate = period.peakRate+ self.feedInRate = period.feedInRate+ self.offPeakSavingsRate = period.offPeakSavingsRate+ }++ /// Reasons a draft can fail validation. Mirrors the backend error codes+ /// (`inverted_dates`, `rate_out_of_range`, `rate_precision`). Overlap+ /// detection runs server-side only.+ public enum ValidationError: Error, Equatable, Sendable {+ case invalidStartDate+ case invertedDates+ case rateOutOfRange+ case ratePrecision+ }++ /// Local pre-flight validation. The server re-validates per Requirement 1+ /// — this is purely for early editor feedback. Returns `nil` when valid.+ public func validate() -> ValidationError? {+ if !Self.isValidDate(startDate) {+ return .invalidStartDate+ }+ if let endDate {+ if !Self.isValidDate(endDate) {+ return .invertedDates+ }+ if endDate < startDate {+ return .invertedDates+ }+ }+ for rate in [peakRate, feedInRate, offPeakSavingsRate] {+ if rate < 0 || rate > 10.0 {+ return .rateOutOfRange+ }+ if !Self.fitsFourDecimalPlaces(rate) {+ return .ratePrecision+ }+ }+ return nil+ }++ /// 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+ }++ 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 }+ let yearString = String(chars[0..<4])+ let monthString = String(chars[5..<7])+ let dayString = String(chars[8..<10])+ guard let year = Int(yearString),+ let month = Int(monthString),+ let day = Int(dayString) 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 }+ return true+ }++ 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+ let rounded = scaled.rounded()+ return abs(scaled - rounded) < 1e-6+ }+}
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swiftnew file mode 100644index 0000000..86003b4--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Pricing/PricingService.swift@@ -0,0 +1,152 @@+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+/// 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.+@MainActor+@Observable+public final class PricingService {+ public static let shared = PricingService()++ public private(set) var periods: [PricingPeriod] = []+ public private(set) var lastError: Error?++ private var apiClient: (any FluxAPIClient)?+ private var refetchTask: Task<Void, Never>?++ public init() {}++ public func bind(apiClient: any FluxAPIClient) {+ self.apiClient = apiClient+ }++ public func clearError() {+ lastError = nil+ }++ public func refresh() async throws {+ guard let apiClient else {+ lastError = FluxAPIError.notConfigured+ throw FluxAPIError.notConfigured+ }+ do {+ let remote = try await apiClient.fetchPricing()+ // 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 }+ lastError = nil+ } catch is CancellationError {+ // Cancelled refreshes are not failures; leave state alone.+ return+ } catch {+ lastError = error+ throw error+ }+ }++ @discardableResult+ public func create(_ draft: PricingPeriodDraft) async throws -> PricingPeriod {+ guard let apiClient else {+ lastError = FluxAPIError.notConfigured+ throw FluxAPIError.notConfigured+ }+ do {+ let created = try await apiClient.createPricing(draft)+ foldInsert(created)+ lastError = nil+ scheduleRefetch()+ return created+ } catch {+ lastError = error+ throw error+ }+ }++ @discardableResult+ public func update(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod {+ guard let apiClient else {+ lastError = FluxAPIError.notConfigured+ throw FluxAPIError.notConfigured+ }+ do {+ let updated = try await apiClient.updatePricing(id: id, draft)+ foldReplace(updated)+ lastError = nil+ scheduleRefetch()+ return updated+ } catch {+ lastError = error+ throw error+ }+ }++ public func delete(id: String) async throws {+ guard let apiClient else {+ lastError = FluxAPIError.notConfigured+ throw FluxAPIError.notConfigured+ }+ do {+ try await apiClient.deletePricing(id: id)+ periods.removeAll { $0.id == id }+ lastError = nil+ scheduleRefetch()+ } catch {+ lastError = error+ throw error+ }+ }++ @discardableResult+ public func replaceOpenEnded(+ closingId: String,+ with draft: PricingPeriodDraft+ ) async throws -> PricingPeriod {+ guard let apiClient else {+ lastError = FluxAPIError.notConfigured+ throw FluxAPIError.notConfigured+ }+ do {+ let result = try await apiClient.replaceOpenEndedPricing(closingId: closingId, with: draft)+ foldInsert(result.closing)+ foldInsert(result.newPeriod)+ lastError = nil+ scheduleRefetch()+ return result.newPeriod+ } catch {+ lastError = error+ throw error+ }+ }++ private func foldInsert(_ period: PricingPeriod) {+ if let idx = periods.firstIndex(where: { $0.id == period.id }) {+ periods[idx] = period+ } else {+ periods.append(period)+ }+ periods.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 }+ } else {+ foldInsert(period)+ }+ }++ /// Cancels any prior fire-and-forget refetch before scheduling a new+ /// one. Stored as a single in-flight handle so a fast sequence of+ /// mutations can't have an older response clobber a newer one, and so+ /// the task isn't leaked across teardown.+ private func scheduleRefetch() {+ refetchTask?.cancel()+ refetchTask = Task { @MainActor [weak self] in+ try? await self?.refresh()+ }+ }+}
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsTests.swiftnew file mode 100644index 0000000..2a35bd1--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayCostsTests.swift@@ -0,0 +1,181 @@+import Foundation+import Testing+@testable import FluxCore++@Suite+struct DayCostsTests {+ // MARK: - DaySummary.costs(forDate:in:)++ @Test+ func costsHappyPathWithFullSplit() 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)++ let costs = summary.costs(forDate: "2026-04-15", in: pricing)+ let unwrapped = try #require(costs)+ // 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)+ }++ @Test+ func costsTreatsNilOffpeakSplitAsZeroAndBillsAllImportsAsPeak() 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: 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)+ }++ @Test+ func costsTreatsNilFieldsAsZero() throws {+ let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 0.10)]+ let summary = DaySummary(+ epv: nil, eInput: nil, eOutput: nil,+ eCharge: nil, eDischarge: nil,+ socLow: nil, socLowTime: nil,+ offpeakGridImportKwh: nil, offpeakGridExportKwh: 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)+ }++ @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)++ 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)+ }++ @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)++ #expect(summary.costs(forDate: "2026-05-01", in: pricing) == nil)+ #expect(summary.costs(forDate: "2026-03-31", in: pricing) == nil)+ }++ @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)++ let aprilCosts = summary.costs(forDate: "2026-04-15", in: pricing)+ #expect(aprilCosts?.peakImportsCost == 7 * 0.30)++ let marchCosts = summary.costs(forDate: "2026-03-15", in: pricing)+ #expect(marchCosts?.peakImportsCost == 7 * 0.20)+ }++ @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)+ 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)+ }++ @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)+ }++ // 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)]+ let day = DayEnergy(+ date: "2026-04-15",+ epv: 0, eInput: 10, eOutput: 4,+ eCharge: 0, eDischarge: 0,+ offpeakGridImportKwh: 3, offpeakGridExportKwh: nil,+ note: nil+ )+ let costs = try #require(day.costs(in: pricing))+ #expect(costs.peakImportsCost == 7 * 0.30)+ #expect(costs.solarFeedInIncome == 4 * 0.05)+ #expect(costs.offPeakSavings == 3 * 0.10)+ }++ @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 day = DayEnergy(+ date: "2026-05-15",+ epv: 0, eInput: 10, eOutput: 4,+ eCharge: 0, eDischarge: 0,+ 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++ private func period(+ id: String = "p",+ start: String,+ end: String?,+ peak: Double,+ feedIn: Double,+ offPeak: Double+ ) -> PricingPeriod {+ PricingPeriod(+ id: id,+ startDate: start,+ endDate: end,+ peakRate: peak,+ feedInRate: feedIn,+ offPeakSavingsRate: offPeak,+ createdAt: Date(timeIntervalSince1970: 1),+ updatedAt: Date(timeIntervalSince1970: 1)+ )+ }++ private func makeSummary(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+ )+ }+}
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PeriodCostsTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PeriodCostsTests.swiftnew file mode 100644index 0000000..8dc11bc--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PeriodCostsTests.swift@@ -0,0 +1,228 @@+import Foundation+import Testing+@testable import FluxCore++@Suite+struct PeriodCostsTests {+ // MARK: - Empty / nil / coverage cases++ @Test+ func emptyDaysReturnsNil() throws {+ let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 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)]+ let days = [+ day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),+ day(date: "2026-04-16", eInput: 12, eOutput: 5, offpeakKwh: 2)+ ]+ #expect(PeriodCosts.compute(days: days, pricing: pricing) == nil)+ }++ @Test+ func fullCoverageNoCaption() throws {+ let pricing = [period(start: "2026-04-01", end: nil, peak: 0.30, feedIn: 0.05, offPeak: 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)+ ]+ let totals = try #require(PeriodCosts.compute(days: days, pricing: pricing))+ #expect(totals.pricedDayCount == 2)+ #expect(totals.totalDayCount == 2)+ #expect(!totals.hasPartialCoverage)+ }++ @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 days = [+ day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),+ day(date: "2026-05-01", eInput: 12, eOutput: 5, offpeakKwh: 2),+ day(date: "2026-05-02", eInput: 8, eOutput: 3, offpeakKwh: 1)+ ]+ let totals = try #require(PeriodCosts.compute(days: days, pricing: pricing))+ #expect(totals.pricedDayCount == 1)+ #expect(totals.totalDayCount == 3)+ #expect(totals.hasPartialCoverage)+ }++ @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 days = [+ day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),+ day(date: "2026-05-01", eInput: 999, eOutput: 999, offpeakKwh: 999)+ ]+ let totals = try #require(PeriodCosts.compute(days: days, pricing: pricing))+ // Only the priced day contributes — peak kWh = 10 - 3 = 7.+ #expect(totals.peakImportsCost == 7 * 0.30)+ #expect(totals.solarFeedInIncome == 4 * 0.05)+ #expect(totals.offPeakSavings == 3 * 0.10)+ #expect(totals.net == 7 * 0.30 - 4 * 0.05)+ }++ // 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 days = [+ day(date: "2026-04-15", eInput: 10, eOutput: 4, offpeakKwh: 3),+ day(date: "2026-04-16", eInput: 12, eOutput: 5, offpeakKwh: 2),+ day(date: "2026-04-17", eInput: 8, eOutput: 3, offpeakKwh: 1)+ ]+ 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))+ }++ // MARK: - Linearity (property-based)++ @Test(arguments: [+ (0.10, 1.0),+ (0.20, 5.0),+ (0.30, 12.5),+ (0.50, 0.0),+ (0.0001, 100.0),+ (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 costs = try #require(summary.costs(forDate: "2026-04-15", in: pricing))+ #expect(approximately(costs.peakImportsCost, rate * kwh))+ #expect(approximately(costs.solarFeedInIncome, rate * kwh))+ }++ @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))+ #expect(costs.peakImportsCost == 0)+ #expect(costs.solarFeedInIncome == 0)+ #expect(costs.offPeakSavings == 0)+ #expect(costs.net == 0)+ }++ @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))+ #expect(costs.peakImportsCost == 0)+ #expect(costs.solarFeedInIncome == 0)+ #expect(costs.offPeakSavings == 0)+ }++ @Test(arguments: [+ (0.10, 1.0, 2.0),+ (0.30, 5.0, 7.5),+ (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 scaled = try #require(summary.costs(forDate: "2026-04-15", in: scaledPricing))+ #expect(approximately(scaled.peakImportsCost, base.peakImportsCost * scale))+ }++ // MARK: - Overlap symmetry (property-based)++ @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+ ])+ 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)+ #expect(rangesOverlap(alpha, beta) == rangesOverlap(beta, alpha))+ }++ @Test(arguments: [+ ("2026-01-01", "2026-06-30", "2026-04-01"),+ ("2026-01-01", nil, "2026-04-01"),+ ("2026-01-01", "2026-06-30", "2026-06-30")+ ])+ 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)+ #expect(rangesOverlap(alpha, beta) == rangesOverlap(beta, alpha))+ }++ // MARK: - helpers++ private func period(+ id: String = "p",+ start: String,+ end: String?,+ peak: Double,+ feedIn: Double,+ offPeak: Double+ ) -> PricingPeriod {+ PricingPeriod(+ id: id,+ startDate: start,+ endDate: end,+ peakRate: peak,+ feedInRate: feedIn,+ offPeakSavingsRate: offPeak,+ createdAt: Date(timeIntervalSince1970: 1),+ updatedAt: Date(timeIntervalSince1970: 1)+ )+ }++ private func day(date: String, eInput: Double, eOutput: Double, offpeakKwh: Double?) -> DayEnergy {+ DayEnergy(+ date: date,+ epv: 0, eInput: eInput, eOutput: eOutput,+ eCharge: 0, eDischarge: 0,+ offpeakGridImportKwh: offpeakKwh,+ offpeakGridExportKwh: nil,+ note: 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 {+ let leftEnd = left.endDate ?? "9999-12-31"+ let rightEnd = right.endDate ?? "9999-12-31"+ return left.startDate <= rightEnd && right.startDate <= leftEnd+ }+}
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingMockURLProtocol.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingMockURLProtocol.swiftnew file mode 100644index 0000000..c9f6ac5--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingMockURLProtocol.swift@@ -0,0 +1,79 @@+import Foundation++/// Test double for URLProtocol used by URLSessionAPIClientPricingTests.+/// Captures the most recent request + body and dispatches to a handler.+final class PricingMockURLProtocol: URLProtocol {+ private static let lock = NSLock()+ private static var _requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?+ private static var _lastRequest: URLRequest?+ private static var _lastRequestBody: Data?++ static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? {+ get {+ lock.lock(); defer { lock.unlock() }+ return _requestHandler+ }+ set {+ lock.lock()+ _requestHandler = newValue+ _lastRequest = nil+ _lastRequestBody = nil+ lock.unlock()+ }+ }++ static var lastRequest: URLRequest? {+ get { lock.lock(); defer { lock.unlock() }; return _lastRequest }+ set { lock.lock(); _lastRequest = newValue; lock.unlock() }+ }++ static var lastRequestBody: Data? {+ lock.lock(); defer { lock.unlock() }+ return _lastRequestBody+ }++ // swiftlint:disable:next static_over_final_class+ override class func canInit(with _: URLRequest) -> Bool { true }+ // swiftlint:disable:next static_over_final_class+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }++ override func startLoading() {+ guard let handler = Self.requestHandler else {+ client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))+ return+ }+ Self.lastRequest = request+ if let stream = request.httpBodyStream {+ Self.lock.lock()+ Self._lastRequestBody = Self.readAll(from: stream)+ Self.lock.unlock()+ } else if let body = request.httpBody {+ Self.lock.lock()+ Self._lastRequestBody = body+ Self.lock.unlock()+ }+ do {+ let (response, data) = try handler(request)+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)+ client?.urlProtocol(self, didLoad: data)+ client?.urlProtocolDidFinishLoading(self)+ } catch {+ client?.urlProtocol(self, didFailWithError: error)+ }+ }++ private static func readAll(from stream: InputStream) -> Data {+ stream.open()+ defer { stream.close() }+ var buffer = [UInt8](repeating: 0, count: 4096)+ var data = Data()+ while stream.hasBytesAvailable {+ let read = stream.read(&buffer, maxLength: buffer.count)+ if read <= 0 { break }+ data.append(buffer, count: read)+ }+ return data+ }++ override func stopLoading() {}+}
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPeriodTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPeriodTests.swiftnew file mode 100644index 0000000..16145cb--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingPeriodTests.swift@@ -0,0 +1,187 @@+import Foundation+import Testing+@testable import FluxCore++@Suite+struct PricingPeriodTests {+ @Test+ func decodeFromBackendShape() throws {+ let jsonString = """+ {+ "id": "pp-1",+ "startDate": "2026-01-01",+ "endDate": "2026-06-30",+ "peakRate": 0.2873,+ "feedInRate": 0.0500,+ "offPeakSavingsRate": 0.1234,+ "createdAt": "2026-05-19T08:00:00Z",+ "updatedAt": "2026-05-19T08:00:00Z"+ }+ """+ let json = Data(jsonString.utf8)+ let period = try jsonDecoder().decode(PricingPeriod.self, from: json)+ #expect(period.id == "pp-1")+ #expect(period.startDate == "2026-01-01")+ #expect(period.endDate == "2026-06-30")+ #expect(period.peakRate == 0.2873)+ #expect(period.feedInRate == 0.05)+ #expect(period.offPeakSavingsRate == 0.1234)+ }++ @Test+ func decodeOpenEndedPeriodWithoutEndDate() throws {+ let jsonString = """+ {+ "id": "pp-open",+ "startDate": "2026-07-01",+ "peakRate": 0.30,+ "feedInRate": 0.06,+ "offPeakSavingsRate": 0.12,+ "createdAt": "2026-07-01T00:00:00Z",+ "updatedAt": "2026-07-01T00:00:00Z"+ }+ """+ let json = Data(jsonString.utf8)+ let period = try jsonDecoder().decode(PricingPeriod.self, from: json)+ #expect(period.endDate == nil)+ }++ @Test+ func encodeRoundTripPreservesFields() throws {+ let period = PricingPeriod(+ id: "pp-1",+ startDate: "2026-01-01",+ endDate: "2026-06-30",+ peakRate: 0.2873,+ feedInRate: 0.05,+ offPeakSavingsRate: 0.12,+ createdAt: Date(timeIntervalSince1970: 1_715_000_000),+ updatedAt: Date(timeIntervalSince1970: 1_715_100_000)+ )+ let data = try jsonEncoder().encode(period)+ let back = try jsonDecoder().decode(PricingPeriod.self, from: data)+ #expect(back == period)+ }++ @Test+ func encodeOpenEndedOmitsEndDate() throws {+ let period = PricingPeriod(+ id: "pp-open",+ startDate: "2026-07-01",+ endDate: nil,+ peakRate: 0.30,+ feedInRate: 0.06,+ offPeakSavingsRate: 0.12,+ createdAt: Date(timeIntervalSince1970: 1),+ updatedAt: Date(timeIntervalSince1970: 1)+ )+ let data = try jsonEncoder().encode(period)+ let dict = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])+ #expect(dict["endDate"] == nil)+ }++ @Test+ func draftEncodesAllFields() throws {+ let draft = PricingPeriodDraft(+ startDate: "2026-01-01",+ endDate: "2026-06-30",+ peakRate: 0.2873,+ feedInRate: 0.05,+ offPeakSavingsRate: 0.12+ )+ let data = try jsonEncoder().encode(draft)+ let dict = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])+ #expect(dict["startDate"] as? String == "2026-01-01")+ #expect(dict["endDate"] as? String == "2026-06-30")+ #expect((dict["peakRate"] as? NSNumber)?.doubleValue == 0.2873)+ }++ @Test+ func iso8601DateStringsSortLexicographicallyAsExpected() {+ // YYYY-MM-DD strings compare correctly chronologically with string compare.+ let earlier = "2026-01-15"+ let later = "2026-02-01"+ #expect(earlier < later)+ #expect("2026-12-31" < "2027-01-01")+ #expect("2026-01-01" < "2026-01-02")+ }++ @Test+ func coversReturnsTrueWithinRange() {+ let period = makePeriod(start: "2026-01-01", end: "2026-06-30")+ #expect(period.covers(date: "2026-01-01"))+ #expect(period.covers(date: "2026-04-15"))+ #expect(period.covers(date: "2026-06-30"))+ }++ @Test+ func coversReturnsFalseOutsideRange() {+ let period = makePeriod(start: "2026-01-01", end: "2026-06-30")+ #expect(!period.covers(date: "2025-12-31"))+ #expect(!period.covers(date: "2026-07-01"))+ }++ @Test+ func coversOpenEndedExtendsForever() {+ let period = makePeriod(start: "2026-07-01", end: nil)+ #expect(period.covers(date: "2026-07-01"))+ #expect(period.covers(date: "2030-01-01"))+ #expect(period.covers(date: "9999-12-31"))+ #expect(!period.covers(date: "2026-06-30"))+ }++ @Test+ func equalityCoversAllFields() {+ let alpha = makePeriod(start: "2026-01-01", end: "2026-06-30")+ let beta = makePeriod(start: "2026-01-01", end: "2026-06-30")+ #expect(alpha == beta)+ let gamma = makePeriod(start: "2026-01-02", end: "2026-06-30")+ #expect(alpha != gamma)+ }++ @Test+ func hashableAcrossSet() {+ let alpha = makePeriod(start: "2026-01-01", end: "2026-06-30")+ let beta = alpha+ let gamma = makePeriod(start: "2026-02-01", end: nil)+ var set: Set<PricingPeriod> = []+ set.insert(alpha)+ set.insert(beta)+ set.insert(gamma)+ #expect(set.count == 2)+ }++ // MARK: - helpers++ private func makePeriod(+ start: String,+ end: String?,+ id: String = "pp",+ peak: Double = 0.30,+ feedIn: Double = 0.05,+ offPeak: Double = 0.12+ ) -> PricingPeriod {+ PricingPeriod(+ id: id,+ startDate: start,+ endDate: end,+ peakRate: peak,+ feedInRate: feedIn,+ offPeakSavingsRate: offPeak,+ createdAt: Date(timeIntervalSince1970: 1),+ updatedAt: Date(timeIntervalSince1970: 1)+ )+ }++ private func jsonEncoder() -> JSONEncoder {+ let enc = JSONEncoder()+ enc.dateEncodingStrategy = .iso8601+ return enc+ }++ private func jsonDecoder() -> JSONDecoder {+ let dec = JSONDecoder()+ dec.dateDecodingStrategy = .iso8601+ return dec+ }+}
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingServiceTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingServiceTests.swiftnew file mode 100644index 0000000..98cd5c4--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/PricingServiceTests.swift@@ -0,0 +1,244 @@+import Foundation+import Testing+@testable import FluxCore++@MainActor @Suite(.serialized)+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 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.lastError == nil)+ }++ @Test+ func refreshStoresLastErrorOnFailure() async throws {+ let api = MockPricingAPIClient()+ api.fetchError = FluxAPIError.serverError+ let svc = PricingService()+ svc.bind(apiClient: api)++ do {+ try await svc.refresh()+ Issue.record("expected throw")+ } catch {+ // expected+ }+ #expect(svc.lastError != nil)+ }++ @Test+ func createFoldsResponseIntoLocalListAndTriggersBackgroundRefetch() async throws {+ let api = MockPricingAPIClient()+ let svc = PricingService()+ svc.bind(apiClient: api)++ let draft = PricingPeriodDraft(+ startDate: "2026-08-01",+ endDate: nil,+ peakRate: 0.30,+ feedInRate: 0.06,+ offPeakSavingsRate: 0.12+ )+ let created = try await svc.create(draft)++ #expect(svc.periods.contains(where: { $0.id == created.id }))++ // Wait for fire-and-forget refetch to complete.+ try await waitForRefetch(api: api, expected: 1)+ #expect(api.fetchCallCount == 1)+ }++ @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 svc = PricingService()+ svc.bind(apiClient: api)+ try await svc.refresh()++ let draft = PricingPeriodDraft(period: existing).with(peakRate: 0.32)+ let updated = try await svc.update(id: existing.id, draft)+ #expect(updated.peakRate == 0.32)+ #expect(svc.periods.first?.peakRate == 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 svc = PricingService()+ svc.bind(apiClient: api)+ try await svc.refresh()++ try await svc.delete(id: existing.id)+ #expect(svc.periods.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 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)+ // After the refetch the API returns the final state.+ api.periodsToReturn = [closed, newOpen]++ let draft = PricingPeriodDraft(period: 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")+ }++ @Test+ func notConfiguredWhenAPIClientNotBound() async throws {+ let svc = PricingService()+ do {+ try await svc.refresh()+ Issue.record("expected throw")+ } catch let error as FluxAPIError {+ #expect(error == .notConfigured)+ }+ }++ @Test+ func clearErrorResetsLastError() async throws {+ let api = MockPricingAPIClient()+ api.fetchError = .serverError+ let svc = PricingService()+ svc.bind(apiClient: api)+ _ = try? await svc.refresh()+ #expect(svc.lastError != nil)+ svc.clearError()+ #expect(svc.lastError == nil)+ }++ // MARK: - helpers++ /// Poll for the API mock's fetch counter to reach the expected value.+ /// Used because the post-mutation refetch is fire-and-forget.+ private func waitForRefetch(api: MockPricingAPIClient, expected: Int) async throws {+ for _ in 0..<50 {+ if api.fetchCallCount >= expected { return }+ try await Task.sleep(nanoseconds: 5_000_000) // 5ms+ }+ 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(+ id: id,+ startDate: start,+ endDate: end,+ peakRate: peakRate,+ feedInRate: 0.05,+ offPeakSavingsRate: 0.12,+ createdAt: Date(timeIntervalSince1970: 1),+ updatedAt: Date(timeIntervalSince1970: 1)+ )+ }+}++private extension PricingPeriodDraft {+ func with(peakRate: Double) -> PricingPeriodDraft {+ var copy = self+ copy.peakRate = peakRate+ return copy+ }+}++// MARK: - Test doubles++final class MockPricingAPIClient: FluxAPIClient, @unchecked Sendable {+ var periodsToReturn: [PricingPeriod] = []+ var fetchError: FluxAPIError?+ var fetchCallCount = 0+ var replaceOpenEndedResult: ReplaceOpenEndedResult?++ 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 { HistoryResponse(days: []) }+ func fetchDay(date: String) async throws -> DayDetailResponse {+ DayDetailResponse(date: date, readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil, note: nil)+ }+ func saveNote(date: String, text _: String) async throws -> NoteResponse {+ NoteResponse(date: date, text: "", updatedAt: nil)+ }++ func fetchPricing() async throws -> [PricingPeriod] {+ fetchCallCount += 1+ if let fetchError { throw fetchError }+ return periodsToReturn+ }++ func createPricing(_ draft: PricingPeriodDraft) async throws -> PricingPeriod {+ let now = Date()+ let period = PricingPeriod(+ id: "mock-\(UUID().uuidString)",+ startDate: draft.startDate,+ endDate: draft.endDate,+ peakRate: draft.peakRate,+ feedInRate: draft.feedInRate,+ offPeakSavingsRate: draft.offPeakSavingsRate,+ createdAt: now,+ updatedAt: now+ )+ periodsToReturn.append(period)+ return period+ }++ func updatePricing(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod {+ guard let idx = periodsToReturn.firstIndex(where: { $0.id == id }) else {+ throw FluxAPIError.notFound+ }+ let now = Date()+ let period = PricingPeriod(+ id: id,+ startDate: draft.startDate,+ endDate: draft.endDate,+ peakRate: draft.peakRate,+ feedInRate: draft.feedInRate,+ offPeakSavingsRate: draft.offPeakSavingsRate,+ createdAt: periodsToReturn[idx].createdAt,+ updatedAt: now+ )+ periodsToReturn[idx] = period+ return period+ }++ func deletePricing(id: String) async throws {+ periodsToReturn.removeAll { $0.id == id }+ }++ func replaceOpenEndedPricing(+ closingId _: String,+ with _: PricingPeriodDraft+ ) async throws -> ReplaceOpenEndedResult {+ if let result = replaceOpenEndedResult { return result }+ throw FluxAPIError.serverError+ }+}
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientPricingTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientPricingTests.swiftnew file mode 100644index 0000000..26155df--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientPricingTests.swift@@ -0,0 +1,447 @@+import Foundation+import Testing+@testable import FluxCore++// swiftlint:disable file_length type_body_length+@MainActor @Suite(.serialized)+struct URLSessionAPIClientPricingTests {+ // MARK: - List++ @Test+ func fetchPricingDecodesArray() 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 = """+ {+ "pricing": [+ {+ "id": "pp-1",+ "startDate": "2026-01-01",+ "endDate": "2026-06-30",+ "peakRate": 0.2873,+ "feedInRate": 0.05,+ "offPeakSavingsRate": 0.12,+ "createdAt": "2026-01-01T00:00:00Z",+ "updatedAt": "2026-01-01T00:00:00Z"+ },+ {+ "id": "pp-2",+ "startDate": "2026-07-01",+ "peakRate": 0.30,+ "feedInRate": 0.06,+ "offPeakSavingsRate": 0.12,+ "createdAt": "2026-07-01T00:00:00Z",+ "updatedAt": "2026-07-01T00:00:00Z"+ }+ ]+ }+ """+ return (response, Data(body.utf8))+ }+ let client = makeClient(session: session)+ let periods = try await client.fetchPricing()++ #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))+ #expect(components.path == "/pricing")+ #expect(request.httpMethod == "GET")+ #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token")+ }++ @Test+ func fetchPricingReturnsEmptyArrayOnEmptyResponse() 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+ )!+ return (response, Data("{\"pricing\": []}".utf8))+ }+ let client = makeClient(session: session)+ let periods = try await client.fetchPricing()+ #expect(periods.isEmpty)+ }++ // MARK: - Create++ @Test+ func createPricingPostsDraftAndDecodesResponse() 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))+ }+ 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)++ #expect(created.id == "pp-new")+ 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")+ #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)+ }++ @Test+ func createPricingMapsOverlapErrorToValidation() async throws {+ let session = makeSession()+ PricingMockURLProtocol.requestHandler = { request in+ let url = try #require(request.url)+ let response = HTTPURLResponse(+ url: url, statusCode: 400, httpVersion: nil, headerFields: nil+ )!+ let body = """+ {"error": "overlap", "openEndedId": "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)+ Issue.record("expected overlap error")+ } catch let error as FluxAPIError {+ guard case let .pricingValidation(reason) = error,+ case let .overlap(openEndedId) = reason else {+ Issue.record("got \(error)")+ return+ }+ #expect(openEndedId == "pp-open-123")+ }+ }++ @Test+ func createPricingMapsAllValidationCodes() async throws {+ let cases: [(String, PricingValidationReason)] = [+ ("inverted_dates", .invertedDates),+ ("rate_precision", .ratePrecision),+ ("rate_out_of_range", .rateOutOfRange),+ ("second_open_ended", .secondOpenEnded)+ ]+ for (code, expectedReason) in cases {+ let session = makeSession()+ PricingMockURLProtocol.requestHandler = { request in+ let url = try #require(request.url)+ let response = HTTPURLResponse(+ url: url, statusCode: 400, httpVersion: nil, headerFields: nil+ )!+ let body = "{\"error\": \"\(code)\"}"+ return (response, Data(body.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)+ Issue.record("expected \(code)")+ } catch let error as FluxAPIError {+ guard case let .pricingValidation(reason) = error else {+ Issue.record("got \(error) for \(code)")+ continue+ }+ #expect(reason == expectedReason, "code \(code)")+ }+ }+ }++ @Test+ func createPricingMaps409ToConcurrentWrite() async throws {+ let session = makeSession()+ PricingMockURLProtocol.requestHandler = { request in+ let url = try #require(request.url)+ let response = HTTPURLResponse(+ url: url, statusCode: 409, httpVersion: nil, headerFields: nil+ )!+ let body = "{\"error\": \"concurrent_open_ended_write\"}"+ return (response, Data(body.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)+ Issue.record("expected concurrent write error")+ } catch let error as FluxAPIError {+ #expect(error == .pricingValidation(.concurrentWrite))+ }+ }++ @Test+ func createPricingMaps401ToUnauthorized() async throws {+ let session = makeSession()+ PricingMockURLProtocol.requestHandler = { request in+ let url = try #require(request.url)+ let response = HTTPURLResponse(+ url: url, statusCode: 401, httpVersion: nil, headerFields: nil+ )!+ 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)+ Issue.record("expected unauthorized")+ } catch let error as FluxAPIError {+ #expect(error == .unauthorized)+ }+ }++ // MARK: - Update++ @Test+ func updatePricingPutsDraftAndDecodesResponse() 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-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))+ }+ 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 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")+ }++ @Test+ func updatePricingMaps404ToNotFoundAsBadRequest() async throws {+ let session = makeSession()+ PricingMockURLProtocol.requestHandler = { request in+ let url = try #require(request.url)+ let response = HTTPURLResponse(+ url: url, statusCode: 404, httpVersion: nil, headerFields: nil+ )!+ 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)+ Issue.record("expected notFound")+ } catch let error as FluxAPIError {+ #expect(error == .notFound)+ }+ }++ // MARK: - Delete++ @Test+ func deletePricingHits204AndReturns() async throws {+ let session = makeSession()+ PricingMockURLProtocol.requestHandler = { request in+ let url = try #require(request.url)+ let response = HTTPURLResponse(+ url: url, statusCode: 204, httpVersion: nil, headerFields: nil+ )!+ return (response, Data())+ }+ let client = makeClient(session: session)+ try await client.deletePricing(id: "pp-1")+ let request = try #require(PricingMockURLProtocol.lastRequest)+ #expect(request.httpMethod == "DELETE")+ let requestURL = try #require(request.url)+ let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false))+ #expect(components.path == "/pricing/pp-1")+ }++ @Test+ func deletePricing404MapsToNotFound() async throws {+ let session = makeSession()+ PricingMockURLProtocol.requestHandler = { request in+ let url = try #require(request.url)+ let response = HTTPURLResponse(+ url: url, statusCode: 404, httpVersion: nil, headerFields: nil+ )!+ return (response, Data())+ }+ let client = makeClient(session: session)+ do {+ try await client.deletePricing(id: "pp-missing")+ Issue.record("expected notFound")+ } catch let error as FluxAPIError {+ #expect(error == .notFound)+ }+ }++ // MARK: - Replace open-ended++ @Test+ func replaceOpenEndedPricingPostsCombinedPayload() 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 = """+ {+ "pricing": [+ {+ "id": "pp-open",+ "startDate": "2026-01-01",+ "endDate": "2026-07-31",+ "peakRate": 0.2873,+ "feedInRate": 0.05,+ "offPeakSavingsRate": 0.12,+ "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"+ }+ ]+ }+ """+ 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)+ #expect(result.closing.id == "pp-open")+ #expect(result.closing.endDate == "2026-07-31")+ #expect(result.newPeriod.id == "pp-new")+ #expect(result.newPeriod.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")+ }++ @Test+ func replaceOpenEndedMaps409ToConcurrentWrite() async throws {+ let session = makeSession()+ PricingMockURLProtocol.requestHandler = { request in+ let url = try #require(request.url)+ let response = HTTPURLResponse(+ url: url, statusCode: 409, httpVersion: nil, headerFields: nil+ )!+ 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)+ Issue.record("expected concurrent")+ } catch let error as FluxAPIError {+ #expect(error == .pricingValidation(.concurrentWrite))+ }+ }++ // MARK: - Helpers+ 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]+ return URLSession(configuration: configuration)+ }+}+// swiftlint:enable file_length type_body_length
diff --git a/cmd/api/main.go b/cmd/api/main.goindex e0f5425..c4ed57d 100644--- a/cmd/api/main.go+++ b/cmd/api/main.go@@ -25,6 +25,7 @@ type config struct { devices api.DeviceStore rules api.SocRuleStore fireState api.FireStateCleaner+ pricing api.PricingStore apiToken string serial string offpeakStart string@@ -42,6 +43,7 @@ var requiredEnvVars = []string{ "TABLE_DEVICES", "TABLE_SOC_RULES", "TABLE_SOC_FIRESTATE",+ "TABLE_PRICING", "OFFPEAK_START", "OFFPEAK_END", "API_TOKEN_PARAM",@@ -62,6 +64,7 @@ func main() { handler.SetDeviceStore(cfg.devices) handler.SetSocRuleStore(cfg.rules) handler.SetFireStateCleaner(cfg.fireState)+ handler.SetPricingStore(cfg.pricing) lambda.Start(handler.Handle) } @@ -115,6 +118,7 @@ func loadConfig(ctx context.Context) (*config, error) { ruleReader := dynamo.NewDynamoSocRuleReader(ddbClient, os.Getenv("TABLE_SOC_RULES")) ruleWriter := dynamo.NewDynamoSocRuleWriter(ddbClient, os.Getenv("TABLE_SOC_RULES")) fireState := dynamo.NewDynamoSocFireStateWriter(ddbClient, os.Getenv("TABLE_SOC_FIRESTATE"))+ pricing := dynamo.NewDynamoPricingStore(ddbClient, os.Getenv("TABLE_PRICING")) return &config{ reader: reader,@@ -122,6 +126,7 @@ func loadConfig(ctx context.Context) (*config, error) { devices: devices, rules: socRuleStoreAdapter{reader: ruleReader, writer: ruleWriter}, fireState: fireStateCleanerAdapter{store: fireState},+ pricing: pricingStoreAdapter{store: pricing}, apiToken: apiToken, serial: serial, offpeakStart: os.Getenv("OFFPEAK_START"),@@ -160,6 +165,42 @@ func (a fireStateCleanerAdapter) DeleteFireStateByDeviceRule(ctx context.Context return a.store.DeleteByDeviceRule(ctx, deviceID, ruleID) } +// pricingStoreAdapter bridges *dynamo.DynamoPricingStore to the+// api.PricingStore interface. Identical shapes — the adapter exists+// because the api package depends only on its local interface so test+// fakes don't import the dynamo writer types.+type pricingStoreAdapter struct {+ store *dynamo.DynamoPricingStore+}++func (a pricingStoreAdapter) ListPricing(ctx context.Context) ([]dynamo.PricingItem, error) {+ return a.store.ListPricing(ctx)+}++func (a pricingStoreAdapter) GetPricing(ctx context.Context, id string) (*dynamo.PricingItem, error) {+ return a.store.GetPricing(ctx, id)+}++func (a pricingStoreAdapter) GetSentinel(ctx context.Context) (*dynamo.PricingSentinel, error) {+ return a.store.GetSentinel(ctx)+}++func (a pricingStoreAdapter) PutPricing(ctx context.Context, item dynamo.PricingItem, prevOpenEndedID *string) error {+ return a.store.PutPricing(ctx, item, prevOpenEndedID)+}++func (a pricingStoreAdapter) UpdatePricing(ctx context.Context, item dynamo.PricingItem, prevOpenEndedID *string) error {+ return a.store.UpdatePricing(ctx, item, prevOpenEndedID)+}++func (a pricingStoreAdapter) DeletePricing(ctx context.Context, id string, prevOpenEndedID *string) error {+ return a.store.DeletePricing(ctx, id, prevOpenEndedID)+}++func (a pricingStoreAdapter) ReplaceOpenEnded(ctx context.Context, closingID, closingEndDate, updatedAt string, newItem dynamo.PricingItem) error {+ return a.store.ReplaceOpenEnded(ctx, closingID, closingEndDate, updatedAt, newItem)+}+ // ssmAPI is the subset of the SSM client used for parameter fetching. type ssmAPI interface { GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error)
diff --git a/cmd/api/main_test.go b/cmd/api/main_test.goindex c08d0ce..dfa0413 100644--- a/cmd/api/main_test.go+++ b/cmd/api/main_test.go@@ -16,6 +16,7 @@ func TestLoadConfigMissingEnv(t *testing.T) { 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")
diff --git a/infrastructure/template.yaml b/infrastructure/template.yamlindex 46e375e..0b6d8ea 100644--- a/infrastructure/template.yaml+++ b/infrastructure/template.yaml@@ -291,6 +291,19 @@ Resources: - dynamodb:DeleteItem Resource: - !GetAtt SocFireStateTable.Arn+ # Pricing table — Lambda is the sole writer (CRUD plus the+ # transactional close-and-create endpoint). Scan covers+ # ListPricing (≤50 rows). TransactWriteItems handles the+ # sentinel-row maintenance from the daily-costs spec.+ - Effect: Allow+ Action:+ - dynamodb:GetItem+ - dynamodb:Scan+ - dynamodb:PutItem+ - dynamodb:UpdateItem+ - dynamodb:DeleteItem+ Resource:+ - !GetAtt PricingTable.Arn - Effect: Allow Action: - ssm:GetParameter@@ -527,6 +540,28 @@ Resources: AttributeName: expiresAt Enabled: true + # --- Pricing table (daily-costs spec) ---+ # User-authored rate periods, low write volume. PITR enabled because+ # pricing is the only user-authored data introduced by this spec and+ # there is no other recovery path. The singleton sentinel row+ # (pricingId = "__open_ended") is created lazily on first write.++ PricingTable:+ Type: AWS::DynamoDB::Table+ DeletionPolicy: Retain+ UpdateReplacePolicy: Retain+ Properties:+ TableName: flux-pricing+ BillingMode: PAY_PER_REQUEST+ AttributeDefinitions:+ - AttributeName: pricingId+ AttributeType: S+ KeySchema:+ - AttributeName: pricingId+ KeyType: HASH+ PointInTimeRecoverySpecification:+ PointInTimeRecoveryEnabled: true+ # --- Lambda Function and Function URL --- ApiFunction:@@ -559,6 +594,7 @@ Resources: TABLE_DEVICES: !Ref DevicesTable TABLE_SOC_RULES: !Ref SocRulesTable TABLE_SOC_FIRESTATE: !Ref SocFireStateTable+ TABLE_PRICING: !Ref PricingTable ApiFunctionUrl: Type: AWS::Lambda::Url
diff --git a/internal/api/handler.go b/internal/api/handler.goindex 04896b2..e71cd4f 100644--- a/internal/api/handler.go+++ b/internal/api/handler.go@@ -26,6 +26,7 @@ type Handler struct { devices DeviceStore rules SocRuleStore fireState FireStateCleaner+ pricing PricingStore serial string apiToken string offpeakStart string@@ -79,6 +80,11 @@ func (h *Handler) buildMux() http.Handler { mux.HandleFunc("POST /devices/{deviceId}/rules", h.handleCreateRule) mux.HandleFunc("PUT /devices/{deviceId}/rules/{ruleId}", h.handleUpdateRule) mux.HandleFunc("DELETE /devices/{deviceId}/rules/{ruleId}", h.handleDeleteRule)+ mux.HandleFunc("GET /pricing", h.handleListPricing)+ mux.HandleFunc("POST /pricing", h.handleCreatePricing)+ mux.HandleFunc("PUT /pricing/{id}", h.handleUpdatePricing)+ mux.HandleFunc("DELETE /pricing/{id}", h.handleDeletePricing)+ mux.HandleFunc("POST /pricing/replace-open-ended", h.handleReplaceOpenEnded) return bearerTokenMiddleware(h.apiToken, jsonNotFound(jsonMethodNotAllowed(mux))) }
diff --git a/internal/api/pricing.go b/internal/api/pricing.gonew file mode 100644index 0000000..94a6c0e--- /dev/null+++ b/internal/api/pricing.go@@ -0,0 +1,47 @@+package api++import (+ "context"++ "github.com/ArjenSchwarz/flux/internal/dynamo"+)++// PricingStore is the api-package-local view of the pricing read+write+// surface. Lambda wiring constructs a *dynamo.DynamoPricingStore and+// passes it in directly; tests substitute an in-memory fake.+type PricingStore interface {+ ListPricing(ctx context.Context) ([]dynamo.PricingItem, error)+ GetPricing(ctx context.Context, id string) (*dynamo.PricingItem, error)+ GetSentinel(ctx context.Context) (*dynamo.PricingSentinel, error)+ PutPricing(ctx context.Context, item dynamo.PricingItem, prevOpenEndedID *string) error+ UpdatePricing(ctx context.Context, item dynamo.PricingItem, prevOpenEndedID *string) error+ DeletePricing(ctx context.Context, id string, prevOpenEndedID *string) error+ 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.+const (+ pricingCodeInvertedDates = "inverted_dates"+ pricingCodeOverlap = "overlap"+ pricingCodeRatePrecision = "rate_precision"+ pricingCodeRateOutOfRange = "rate_out_of_range"+ pricingCodeSecondOpenEnded = "second_open_ended"+ pricingCodeConcurrentWrite = "concurrent_open_ended_write"+ 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.+const pricingBodyMaxBytes = 4096
diff --git a/internal/api/pricing_handler.go b/internal/api/pricing_handler.gonew file mode 100644index 0000000..ed4fe68--- /dev/null+++ b/internal/api/pricing_handler.go@@ -0,0 +1,535 @@+package api++import (+ "encoding/json"+ "errors"+ "io"+ "log/slog"+ "math"+ "net/http"+ "time"++ "github.com/ArjenSchwarz/flux/internal/dynamo"+)++// 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.+type pricingPayload struct {+ StartDate string `json:"startDate"`+ EndDate *string `json:"endDate,omitempty"`+ PeakRate *float64 `json:"peakRate"`+ FeedInRate *float64 `json:"feedInRate"`+ OffPeakSavingsRate *float64 `json:"offPeakSavingsRate"`+}++// replaceOpenEndedPayload is the wire shape of+// POST /pricing/replace-open-ended.+type replaceOpenEndedPayload struct {+ ClosingPricingID string `json:"closingPricingId"`+ NewPeriod pricingPayload `json:"newPeriod"`+}++// 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) {+ h.pricing = s+}++// 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.+type pricingError struct {+ Error string `json:"error"`+ Message string `json:"message"`+ OpenEndedID string `json:"openEndedId,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".+func writePricingError(w http.ResponseWriter, status int, code, message string) {+ body, err := json.Marshal(pricingError{Error: code, Message: message})+ if err != nil {+ slog.Error("marshal pricing error", "error", err)+ body = []byte(`{"error":"internal_error","message":"failed to serialise error"}`)+ }+ w.Header().Set("Content-Type", "application/json")+ w.WriteHeader(status)+ _, _ = 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,+ })+ if err != nil {+ slog.Error("marshal pricing overlap error", "error", err)+ body = []byte(`{"error":"overlap","message":"pricing period 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.+func (h *Handler) handleListPricing(w http.ResponseWriter, r *http.Request) {+ if h.pricing == nil {+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "pricing store not configured")+ return+ }+ rows, err := h.pricing.ListPricing(r.Context())+ if err != nil {+ slog.Error("list pricing failed", "error", err)+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "list pricing failed")+ return+ }+ writeJSON(w, http.StatusOK, struct {+ Pricing []dynamo.PricingItem `json:"pricing"`+ }{Pricing: rows})+}++// handleCreatePricing validates the payload, enforces AC 1.10 ordering,+// 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")+ return+ }+ payload, ok := decodePricingPayload(w, r)+ if !ok {+ return+ }++ existing, err := h.pricing.ListPricing(r.Context())+ if err != nil {+ slog.Error("list pricing failed during create", "error", err)+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "list pricing failed")+ return+ }+ if !runPricingValidationChain(w, payload, existing, "") {+ return+ }++ prevOpenEndedID := openEndedIDFromList(existing)+ now := h.nowFunc().UTC().Format(time.RFC3339)+ item := payload.toItem(h.idFunc(), now, now)++ if err := h.pricing.PutPricing(r.Context(), item, prevOpenEndedID); err != nil {+ mapPricingStoreError(w, "put pricing", err)+ return+ }+ writeJSON(w, http.StatusCreated, item)+}++// handleUpdatePricing validates and overwrites an existing pricing row.+// AC 1.7 / 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")+ return+ }+ id := r.PathValue("id")+ if id == "" {+ writePricingError(w, http.StatusBadRequest, pricingCodeInternal, "id required")+ return+ }+ payload, ok := decodePricingPayload(w, r)+ if !ok {+ return+ }++ existing, err := h.pricing.ListPricing(r.Context())+ if err != nil {+ slog.Error("list pricing failed during update", "error", err)+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "list pricing failed")+ return+ }+ var current *dynamo.PricingItem+ for i := range existing {+ if existing[i].PricingID == id {+ current = &existing[i]+ break+ }+ }+ if current == nil {+ writePricingError(w, http.StatusNotFound, pricingCodeInternal, "pricing period not found")+ return+ }+ if !runPricingValidationChain(w, payload, existing, id) {+ return+ }++ prevOpenEndedID := openEndedIDFromList(existing)+ now := h.nowFunc().UTC().Format(time.RFC3339)+ item := payload.toItem(id, current.CreatedAt, now)++ if err := h.pricing.UpdatePricing(r.Context(), item, prevOpenEndedID); err != nil {+ mapPricingStoreError(w, "update pricing", err)+ return+ }+ writeJSON(w, http.StatusOK, item)+}++// handleDeletePricing removes a pricing row by id. AC 2.4 / 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")+ return+ }+ id := r.PathValue("id")+ if id == "" {+ writePricingError(w, http.StatusBadRequest, pricingCodeInternal, "id required")+ return+ }+ existing, err := h.pricing.GetPricing(r.Context(), id)+ if err != nil {+ slog.Error("get pricing failed during delete", "id", id, "error", err)+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "get pricing failed")+ return+ }+ if existing == nil {+ writePricingError(w, http.StatusNotFound, pricingCodeInternal, "pricing period not found")+ return+ }+ sentinel, err := h.pricing.GetSentinel(r.Context())+ if err != nil {+ slog.Error("get sentinel failed during delete", "error", err)+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "get sentinel failed")+ return+ }+ var prevOpenEndedID *string+ if sentinel != nil {+ prevOpenEndedID = sentinel.OpenEndedID+ }+ if err := h.pricing.DeletePricing(r.Context(), id, prevOpenEndedID); err != nil {+ mapPricingStoreError(w, "delete pricing", err)+ return+ }+ w.Header().Set("Content-Type", "application/json")+ 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.+func (h *Handler) handleReplaceOpenEnded(w http.ResponseWriter, r *http.Request) {+ if h.pricing == nil {+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "pricing store not configured")+ return+ }+ r.Body = http.MaxBytesReader(w, r.Body, pricingBodyMaxBytes)+ body, err := io.ReadAll(r.Body)+ if err != nil {+ writePricingError(w, http.StatusBadRequest, pricingCodeInternal, "malformed request body")+ return+ }+ var payload replaceOpenEndedPayload+ if err := json.Unmarshal(body, &payload); err != nil {+ writePricingError(w, http.StatusBadRequest, pricingCodeInternal, "malformed request body")+ return+ }+ if payload.ClosingPricingID == "" {+ writePricingError(w, http.StatusBadRequest, pricingCodeInternal, "closingPricingId required")+ return+ }++ existing, err := h.pricing.ListPricing(r.Context())+ if err != nil {+ slog.Error("list pricing failed during replace-open-ended", "error", err)+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "list pricing failed")+ return+ }+ var closing *dynamo.PricingItem+ for i := range existing {+ if existing[i].PricingID == payload.ClosingPricingID {+ closing = &existing[i]+ break+ }+ }+ if closing == nil {+ writePricingError(w, http.StatusNotFound, pricingCodeInternal, "closing pricing period not found")+ return+ }+ if closing.EndDate != nil {+ writePricingError(w, http.StatusBadRequest, pricingCodeSecondOpenEnded, "closing period is not open-ended")+ return+ }++ closingEndDate, err := previousDate(payload.NewPeriod.StartDate)+ if err != nil {+ writePricingError(w, http.StatusBadRequest, pricingCodeInvertedDates, "newPeriod.startDate invalid")+ 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.+ projected := make([]dynamo.PricingItem, 0, len(existing)+1)+ for _, row := range existing {+ if row.PricingID == payload.ClosingPricingID {+ rowCopy := row+ end := closingEndDate+ rowCopy.EndDate = &end+ projected = append(projected, rowCopy)+ } else {+ projected = append(projected, row)+ }+ }+ if !runPricingValidationChain(w, payload.NewPeriod, projected, "") {+ return+ }++ now := h.nowFunc().UTC().Format(time.RFC3339)+ newItem := payload.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+ }++ // Return the resulting pair from the in-memory transaction inputs so+ // the client can fold both rows back into its local list without a+ // second fetch. Re-scanning here would be eventually-consistent and+ // could return stale rows for the just-committed transaction. The+ // `now` value is the same string the store wrote into the closing+ // row, so the response carries the canonical updatedAt.+ closingRow := *closing+ closingRow.EndDate = &closingEndDate+ closingRow.UpdatedAt = now+ writeJSON(w, http.StatusOK, struct {+ Pricing []dynamo.PricingItem `json:"pricing"`+ }{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.+func decodePricingPayload(w http.ResponseWriter, r *http.Request) (pricingPayload, bool) {+ r.Body = http.MaxBytesReader(w, r.Body, pricingBodyMaxBytes)+ body, err := io.ReadAll(r.Body)+ if err != nil {+ writePricingError(w, http.StatusBadRequest, pricingCodeInternal, "malformed request body")+ return pricingPayload{}, false+ }+ var payload pricingPayload+ if err := json.Unmarshal(body, &payload); err != nil {+ writePricingError(w, http.StatusBadRequest, pricingCodeInternal, "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.+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,+ }+}++func deref(p *float64) float64 {+ if p == nil {+ return 0+ }+ 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.+//+// `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+ }+ if !validateOverlap(w, p, existing, excludeID) {+ return false+ }+ if !validateRatePrecision(w, p) {+ return false+ }+ if !validateRateRange(w, p) {+ return false+ }+ if !validateSecondOpenEnded(w, p, existing, excludeID) {+ return false+ }+ return true+}++// 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).+// Open-ended is modelled as endDate = "9999-12-31".+func validateOverlap(w http.ResponseWriter, p pricingPayload, existing []dynamo.PricingItem, excludeID string) bool {+ candEnd := pricingMaxEndDate+ if p.EndDate != nil {+ candEnd = *p.EndDate+ }+ for _, row := range existing {+ if row.PricingID == excludeID {+ continue+ }+ rowEnd := pricingMaxEndDate+ 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")+ 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.+func validateSecondOpenEnded(w http.ResponseWriter, p pricingPayload, existing []dynamo.PricingItem, excludeID string) bool {+ if p.EndDate != nil {+ return true+ }+ for _, row := range existing {+ if row.PricingID == excludeID {+ continue+ }+ if row.EndDate == nil {+ writePricingError(w, http.StatusBadRequest, pricingCodeSecondOpenEnded, "another pricing period is already open-ended")+ return false+ }+ }+ return true+}++// openEndedIDFromList returns the unique open-ended row's id, or nil+// when no open-ended row exists. The validator's overlap and+// second_open_ended checks already ensure at most one is present.+func openEndedIDFromList(rows []dynamo.PricingItem) *string {+ for _, row := range rows {+ if row.EndDate == nil {+ id := row.PricingID+ return &id+ }+ }+ return nil+}++// mapPricingStoreError translates typed errors from the dynamo store+// into the appropriate HTTP response. Anything unrecognised falls+// through to HTTP 500 with the raw error logged.+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.ErrPricingUUIDCollision):+ slog.Warn("pricing uuid collision", "op", op, "error", err)+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "uuid collision; retry")+ default:+ slog.Error("pricing store error", "op", op, "error", err)+ writePricingError(w, http.StatusInternalServerError, pricingCodeInternal, "internal 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 {+ return math.Round(v*10000) / 10000+}
diff --git a/internal/api/pricing_test.go b/internal/api/pricing_test.gonew file mode 100644index 0000000..cf8e565--- /dev/null+++ b/internal/api/pricing_test.go@@ -0,0 +1,631 @@+package api++import (+ "context"+ "encoding/json"+ "errors"+ "fmt"+ "net/http"+ "sync"+ "testing"+ "time"++ "github.com/ArjenSchwarz/flux/internal/dynamo"+ "github.com/aws/aws-lambda-go/events"+ "github.com/stretchr/testify/assert"+ "github.com/stretchr/testify/require"+)++// fakePricingStore implements the PricingStore interface in-memory so the+// API handlers exercise the real validation + sentinel-aware writes path.+// The store tracks the open-ended row separately to mirror the production+// sentinel contract, which lets concurrent-race tests inject failures via+// the typed errors returned by the production transactional methods.+type fakePricingStore struct {+ mu sync.Mutex+ rows map[string]dynamo.PricingItem // pricingId -> row+ openEndedID *string+ listErr error+ getErr error+ putErr error+ updateErr error+ deleteErr error+ replaceErr error+ getSentErr error+}++func newFakePricingStore() *fakePricingStore {+ return &fakePricingStore{rows: make(map[string]dynamo.PricingItem)}+}++func (s *fakePricingStore) ListPricing(_ context.Context) ([]dynamo.PricingItem, error) {+ s.mu.Lock()+ defer s.mu.Unlock()+ if s.listErr != nil {+ return nil, s.listErr+ }+ out := make([]dynamo.PricingItem, 0, len(s.rows))+ for _, row := range s.rows {+ out = append(out, row)+ }+ // Reader-side sort matches production: by StartDate ascending.+ sortPricingByStart(out)+ return out, nil+}++func (s *fakePricingStore) GetPricing(_ context.Context, id string) (*dynamo.PricingItem, error) {+ s.mu.Lock()+ defer s.mu.Unlock()+ if s.getErr != nil {+ return nil, s.getErr+ }+ if row, ok := s.rows[id]; ok {+ c := row+ return &c, nil+ }+ return nil, nil+}++func (s *fakePricingStore) GetSentinel(_ context.Context) (*dynamo.PricingSentinel, error) {+ s.mu.Lock()+ defer s.mu.Unlock()+ if s.getSentErr != nil {+ return nil, s.getSentErr+ }+ if s.openEndedID == nil {+ // Match production: an absent sentinel returns nil and the+ // validator treats that as "no open-ended period exists."+ return nil, nil+ }+ openCopy := *s.openEndedID+ return &dynamo.PricingSentinel{+ PricingID: "__open_ended",+ OpenEndedID: &openCopy,+ }, nil+}++func (s *fakePricingStore) PutPricing(_ context.Context, item dynamo.PricingItem, _ *string) error {+ s.mu.Lock()+ defer s.mu.Unlock()+ if s.putErr != nil {+ return s.putErr+ }+ s.rows[item.PricingID] = item+ if item.EndDate == nil {+ id := item.PricingID+ s.openEndedID = &id+ }+ return nil+}++func (s *fakePricingStore) UpdatePricing(_ context.Context, item dynamo.PricingItem, _ *string) error {+ s.mu.Lock()+ defer s.mu.Unlock()+ if s.updateErr != nil {+ return s.updateErr+ }+ s.rows[item.PricingID] = item+ if item.EndDate == nil {+ id := item.PricingID+ s.openEndedID = &id+ } else if s.openEndedID != nil && *s.openEndedID == item.PricingID {+ s.openEndedID = nil+ }+ return nil+}++func (s *fakePricingStore) DeletePricing(_ context.Context, id string, _ *string) error {+ s.mu.Lock()+ defer s.mu.Unlock()+ if s.deleteErr != nil {+ return s.deleteErr+ }+ delete(s.rows, id)+ if s.openEndedID != nil && *s.openEndedID == id {+ s.openEndedID = nil+ }+ return nil+}++func (s *fakePricingStore) ReplaceOpenEnded(_ context.Context, closingID, closingEndDate, updatedAt string, newItem dynamo.PricingItem) error {+ s.mu.Lock()+ defer s.mu.Unlock()+ if s.replaceErr != nil {+ return s.replaceErr+ }+ closing, ok := s.rows[closingID]+ if !ok {+ return fmt.Errorf("closing row not found")+ }+ end := closingEndDate+ closing.EndDate = &end+ closing.UpdatedAt = updatedAt+ s.rows[closingID] = closing+ s.rows[newItem.PricingID] = newItem+ if newItem.EndDate == nil {+ id := newItem.PricingID+ s.openEndedID = &id+ } else {+ s.openEndedID = nil+ }+ return nil+}++func sortPricingByStart(rows []dynamo.PricingItem) {+ // Bubble sort is sufficient: tests use at most ~5 rows.+ for i := 0; i < len(rows); i++ {+ for j := i + 1; j < len(rows); j++ {+ if rows[j].StartDate < rows[i].StartDate {+ rows[i], rows[j] = rows[j], rows[i]+ }+ }+ }+}++func newPricingTestHandler(store *fakePricingStore) *Handler {+ h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+ h.SetPricingStore(store)+ fixedNow := time.Date(2026, 5, 23, 10, 0, 0, 0, time.UTC)+ h.nowFunc = func() time.Time { return fixedNow }+ counter := 0+ h.idFunc = func() string {+ counter+++ return fmt.Sprintf("pricing-uuid-%d", counter)+ }+ h.mux = h.buildMux()+ return h+}++func makeJSONRequest(method, path, body string) events.LambdaFunctionURLRequest {+ req := makeRequest(method, path, "Bearer "+testToken)+ req.Body = body+ req.Headers["content-type"] = "application/json"+ return req+}++// Generic typed-error shape used to inspect the {"error","message"}+// response body.+type pricingErrorBody struct {+ Error string `json:"error"`+ Message string `json:"message"`+}++func decodeError(t *testing.T, raw string) pricingErrorBody {+ t.Helper()+ var body pricingErrorBody+ require.NoError(t, json.Unmarshal([]byte(raw), &body), "response body must be JSON")+ return body+}++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"}+ h := newPricingTestHandler(store)++ resp, err := h.Handle(context.Background(), makeRequest(http.MethodGet, "/pricing", "Bearer "+testToken))+ require.NoError(t, err)+ require.Equal(t, http.StatusOK, resp.StatusCode)++ var body struct {+ Pricing []dynamo.PricingItem `json:"pricing"`+ }+ require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))+ 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)+}++func TestPricing_ListReturns401WithoutToken(t *testing.T) {+ store := newFakePricingStore()+ h := newPricingTestHandler(store)++ resp, err := h.Handle(context.Background(), makeRequest(http.MethodGet, "/pricing", ""))+ require.NoError(t, err)+ assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)+}++func TestPricing_CreateClosedPeriodAssignsIDAndTimestamps(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))+ require.NoError(t, err)+ require.Equal(t, http.StatusCreated, resp.StatusCode)++ 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, got.CreatedAt, got.UpdatedAt)+}++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))+ require.NoError(t, err)+ require.Equal(t, http.StatusCreated, resp.StatusCode)++ var got dynamo.PricingItem+ require.NoError(t, json.Unmarshal([]byte(resp.Body), &got))+ assert.Nil(t, got.EndDate, "open-ended period must serialise endDate as absent")+}++func TestPricing_CreateValidationErrorsByCode(t *testing.T) {+ // AC 2.3: every validation rule maps to a single machine-parseable+ // error code from the documented set.+ 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}`,+ code: "inverted_dates",+ },+ "rate_precision peak": {+ body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.12345,"feedInRate":0.05,"offPeakSavingsRate":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}`,+ code: "rate_precision",+ },+ "rate_precision offPeakSavings": {+ body: `{"startDate":"2026-01-01","endDate":"2026-12-31","peakRate":0.1,"feedInRate":0.05,"offPeakSavingsRate":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}`,+ 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}`,+ 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}`,+ 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}`,+ code: "rate_out_of_range",+ },+ }+ for name, tc := range cases {+ t.Run(name, func(t *testing.T) {+ store := newFakePricingStore()+ h := newPricingTestHandler(store)+ resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", tc.body))+ 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)+ })+ }+}++func TestPricing_CreateRejectsOverlap(t *testing.T) {+ 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}`+ 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)+}++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.+ 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+ 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))+ require.NoError(t, err)+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)++ 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_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.+ 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.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))+ 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")+}++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.+ 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",+ }+ 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}`+ 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")++ // 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}`+ 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")+}++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",+ }+ 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))+ require.NoError(t, err)+ require.Equal(t, http.StatusOK, resp.StatusCode)++ 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.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",+ }+ 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))+ require.NoError(t, err)+ assert.Equal(t, http.StatusOK, resp.StatusCode)+}++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))+ 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",+ }+ h := newPricingTestHandler(store)++ resp, err := h.Handle(context.Background(), makeRequest(http.MethodDelete, "/pricing/p-1", "Bearer "+testToken))+ require.NoError(t, err)+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)+ assert.Empty(t, store.rows, "row must be removed")+}++func TestPricing_DeleteUnknownIdReturns404(t *testing.T) {+ // AC 2.4 / Decision 11: delete returns 404 on unknown id.+ store := newFakePricingStore()+ h := newPricingTestHandler(store)++ resp, err := h.Handle(context.Background(), makeRequest(http.MethodDelete, "/pricing/missing", "Bearer "+testToken))+ require.NoError(t, err)+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)+}++func TestPricing_ReplaceOpenEndedHappyPath(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+ 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)+ require.Equal(t, http.StatusOK, resp.StatusCode, "response: %s", resp.Body)++ var got struct {+ Pricing []dynamo.PricingItem `json:"pricing"`+ }+ 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.Nil(t, got.Pricing[1].EndDate)+}++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}}`+ 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)+}++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",+ }+ 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}}`+ 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)+ assert.Equal(t, "concurrent_open_ended_write", decodeError(t, resp.Body).Error)+}++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",+ }+ 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}}`+ 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)+ assert.Equal(t, "internal_error", decodeError(t, resp.Body).Error)+}++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))+ require.NoError(t, err)+ assert.Equal(t, http.StatusConflict, resp.StatusCode)+ assert.Equal(t, "concurrent_open_ended_write", decodeError(t, resp.Body).Error)+}++func TestPricing_MalformedBodyReturns400(t *testing.T) {+ store := newFakePricingStore()+ h := newPricingTestHandler(store)++ resp, err := h.Handle(context.Background(), makeJSONRequest(http.MethodPost, "/pricing", `{ not json`))+ require.NoError(t, err)+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+}++func TestPricing_StoreFailureMapsTo500(t *testing.T) {+ store := newFakePricingStore()+ store.listErr = errors.New("dynamo down")+ h := newPricingTestHandler(store)++ resp, err := h.Handle(context.Background(), makeRequest(http.MethodGet, "/pricing", "Bearer "+testToken))+ require.NoError(t, err)+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)+}
diff --git a/internal/dynamo/pricing.go b/internal/dynamo/pricing.gonew file mode 100644index 0000000..603b363--- /dev/null+++ b/internal/dynamo/pricing.go@@ -0,0 +1,229 @@+package dynamo++import (+ "context"+ "fmt"+ "sort"++ "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+// 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"++// PricingItem represents one row of the flux-pricing table. PricingID is+// serialised as "id" so the Swift client decodes it through Identifiable.+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+}++// PricingSentinel is the singleton row (pricingId = "__open_ended") whose+// OpenEndedID attribute points at the pricing row that currently has no+// end date — or is absent when no open-ended period exists. Every write+// that introduces, retires, or replaces an open-ended period maintains+// this row inside the same TransactWriteItems request so AC 1.9 ("at+// most one open-ended period") survives concurrent writers.+type PricingSentinel struct {+ PricingID string `dynamodbav:"pricingId"`+ OpenEndedID *string `dynamodbav:"openEndedId,omitempty"`+ UpdatedAt string `dynamodbav:"updatedAt"`+}++// PricingReadAPI is the read surface exposed to the API handler.+// ListPricing excludes the sentinel row. GetSentinel returns nil before+// the sentinel has been lazily provisioned (treated as "no open-ended+// period exists" by the validator).+type PricingReadAPI interface {+ ListPricing(ctx context.Context) ([]PricingItem, error)+ GetPricing(ctx context.Context, id string) (*PricingItem, error)+ GetSentinel(ctx context.Context) (*PricingSentinel, error)+}++// PricingWriteAPI is the write surface exposed to the API handler.+// prevOpenEndedID is the sentinel's openEndedId value the validator+// captured just before the write; transactional writes use it inside a+// ConditionExpression so concurrent writers race the sentinel.+type PricingWriteAPI interface {+ PutPricing(ctx context.Context, item PricingItem, prevOpenEndedID *string) error+ UpdatePricing(ctx context.Context, item PricingItem, prevOpenEndedID *string) error+ DeletePricing(ctx context.Context, id string, prevOpenEndedID *string) error+ ReplaceOpenEnded(ctx context.Context, closingID string, closingEndDate string, newItem PricingItem) error+}++// PricingAPI is the subset of the DynamoDB client used by the pricing+// store. The live *dynamodb.Client satisfies every method.+type PricingAPI interface {+ PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error)+ 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)+ DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error)+ Scan(ctx context.Context, params *dynamodb.ScanInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error)+ TransactWriteItems(ctx context.Context, params *dynamodb.TransactWriteItemsInput, optFns ...func(*dynamodb.Options)) (*dynamodb.TransactWriteItemsOutput, error)+}++// DynamoPricingStore implements both PricingReadAPI and PricingWriteAPI+// against a real DynamoDB table.+type DynamoPricingStore struct {+ client PricingAPI+ table string+}++// NewDynamoPricingStore returns a store scoped to the given table name.+func NewDynamoPricingStore(client PricingAPI, table string) *DynamoPricingStore {+ return &DynamoPricingStore{client: client, table: table}+}++// pricingKey returns the DynamoDB key for a pricing row.+func pricingKey(id string) map[string]types.AttributeValue {+ return map[string]types.AttributeValue{+ "pricingId": &types.AttributeValueMemberS{Value: id},+ }+}++// ListPricing returns every pricing row sorted by StartDate ascending.+// The sentinel row (pricingId = "__open_ended") is filtered out so the+// API never exposes it to clients.+func (s *DynamoPricingStore) ListPricing(ctx context.Context) ([]PricingItem, error) {+ items := make([]PricingItem, 0)+ input := &dynamodb.ScanInput{TableName: &s.table}+ for {+ out, err := s.client.Scan(ctx, input)+ if err != nil {+ return nil, fmt.Errorf("scan pricing (table=%s): %w", s.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 {+ continue+ }+ var item PricingItem+ if err := attributevalue.UnmarshalMap(av, &item); err != nil {+ return nil, fmt.Errorf("unmarshal pricing (table=%s): %w", s.table, err)+ }+ items = append(items, item)+ }+ if out.LastEvaluatedKey == nil {+ break+ }+ input.ExclusiveStartKey = out.LastEvaluatedKey+ }+ sort.SliceStable(items, func(i, j int) bool {+ return items[i].StartDate < items[j].StartDate+ })+ return items, nil+}++// GetPricing returns the pricing row with the given id, or nil if absent.+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),+ )+}++// 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),+ fmt.Sprintf("pricing sentinel (table=%s)", s.table),+ )+}++// PutPricing inserts a new pricing row. For a closed period it issues a+// plain PutItem; for an open-ended period it co-writes the sentinel+// inside a TransactWriteItems request with a ConditionExpression on the+// sentinel's previous value (Decision 21).+func (s *DynamoPricingStore) PutPricing(ctx context.Context, item PricingItem, prevOpenEndedID *string) error {+ if item.EndDate != nil {+ return s.putClosedPeriod(ctx, item)+ }+ return s.putOpenEndedPeriod(ctx, item, prevOpenEndedID)+}++func (s *DynamoPricingStore) putClosedPeriod(ctx context.Context, item PricingItem) error {+ av, err := attributevalue.MarshalMap(item)+ if err != nil {+ return fmt.Errorf("marshal pricing (pricingId=%s): %w", item.PricingID, err)+ }+ cond := "attribute_not_exists(pricingId)"+ _, err = s.client.PutItem(ctx, &dynamodb.PutItemInput{+ TableName: &s.table,+ Item: av,+ ConditionExpression: &cond,+ })+ if err != nil {+ return fmt.Errorf("put pricing (table=%s, pricingId=%s): %w", s.table, item.PricingID, err)+ }+ return nil+}++// UpdatePricing updates an existing pricing row. The behaviour depends+// on the pre/post open-ended state:+//+// - closed → closed: plain UpdateItem.+// - closed → open: TransactWriteItems (sentinel null → rowID, row update).+// - open → closed: TransactWriteItems (sentinel rowID → null, row update).+// - open → open: TransactWriteItems (sentinel rowID → rowID guard, row update).+//+// prevOpenEndedID is the sentinel's openEndedId value captured by the+// validator just before this write.+func (s *DynamoPricingStore) UpdatePricing(ctx context.Context, item PricingItem, prevOpenEndedID *string) error {+ wasOpen := prevOpenEndedID != nil && *prevOpenEndedID == item.PricingID+ isOpen := item.EndDate == nil++ if !wasOpen && !isOpen {+ return s.updateClosedToClosed(ctx, item)+ }+ return s.updateOpenEndedTransition(ctx, item, prevOpenEndedID, isOpen)+}++func (s *DynamoPricingStore) updateClosedToClosed(ctx context.Context, item PricingItem) error {+ av, err := attributevalue.MarshalMap(item)+ if err != nil {+ return fmt.Errorf("marshal pricing (pricingId=%s): %w", item.PricingID, err)+ }+ // Overwriting the row preserves correctness because the caller has+ // already fetched the existing row to discover createdAt.+ cond := "attribute_exists(pricingId)"+ _, err = s.client.PutItem(ctx, &dynamodb.PutItemInput{+ TableName: &s.table,+ Item: av,+ ConditionExpression: &cond,+ })+ if err != nil {+ return fmt.Errorf("update pricing (table=%s, pricingId=%s): %w", s.table, item.PricingID, err)+ }+ return nil+}++// DeletePricing removes a pricing row. For a closed period it issues a+// plain DeleteItem; for the open-ended period it co-writes the sentinel+// (rowID → null) inside a TransactWriteItems request.+func (s *DynamoPricingStore) DeletePricing(ctx context.Context, id string, prevOpenEndedID *string) error {+ deletingOpenEnded := prevOpenEndedID != nil && *prevOpenEndedID == id+ if !deletingOpenEnded {+ _, err := s.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{+ TableName: &s.table,+ Key: pricingKey(id),+ })+ if err != nil {+ return fmt.Errorf("delete pricing (table=%s, pricingId=%s): %w", s.table, id, err)+ }+ return nil+ }+ return s.deleteOpenEndedPeriod(ctx, id, prevOpenEndedID)+}
diff --git a/internal/dynamo/pricing_atomicity_test.go b/internal/dynamo/pricing_atomicity_test.gonew file mode 100644index 0000000..f694ee7--- /dev/null+++ b/internal/dynamo/pricing_atomicity_test.go@@ -0,0 +1,192 @@+package dynamo++import (+ "context"+ "errors"+ "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"+)++// reasonAt builds a CancellationReasons slice with `code` at position+// idx and `None` everywhere else, mirroring how DynamoDB reports a+// single failing item inside a TransactWriteItems request.+func reasonAt(idx, total int, code string) []types.CancellationReason {+ reasons := make([]types.CancellationReason, total)+ none := "None"+ for i := range reasons {+ reasons[i] = types.CancellationReason{Code: &none}+ }+ reasons[idx] = types.CancellationReason{Code: &code}+ return reasons+}++// canceledWith constructs a TransactionCanceledException carrying the+// given CancellationReasons. Returned via errors.As to match the+// production mapper.+func canceledWith(reasons []types.CancellationReason) error {+ return &types.TransactionCanceledException{+ CancellationReasons: reasons,+ }+}++// 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.+func newAtomicityMock(transactErr error) *mockPricingAPI {+ return &mockPricingAPI{+ transactWriteFn: func(_ context.Context, _ *dynamodb.TransactWriteItemsInput) (*dynamodb.TransactWriteItemsOutput, error) {+ return nil, transactErr+ },+ }+}++// Case 1: sentinel race on replace-open-ended (Reasons[0] = ConditionalCheckFailed).+func TestPricingAtomicity_ReplaceOpenEnded_SentinelRace(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(0, 3, "ConditionalCheckFailed")))+ 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})+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingConcurrentWrite,+ "sentinel position must map to concurrent_open_ended_write")+}++// Case 2: closing-row race on replace-open-ended (Reasons[1] = ConditionalCheckFailed).+func TestPricingAtomicity_ReplaceOpenEnded_ClosingRowRace(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(1, 3, "ConditionalCheckFailed")))+ 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})+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingConcurrentWrite,+ "closing-row position must map to concurrent_open_ended_write")+}++// Case 3: UUID collision on replace-open-ended new row (Reasons[2] = ConditionalCheckFailed).+func TestPricingAtomicity_ReplaceOpenEnded_UUIDCollision(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(2, 3, "ConditionalCheckFailed")))+ 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})+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingUUIDCollision,+ "new-row position must map to uuid_collision so the caller retries")+}++// Case 4: TransactionCanceledException with an unpopulated Reasons slice+// (SDK quirk). The mapper must fall through to a wrapped error that the+// handler renders as HTTP 500 with the raw exception logged.+func TestPricingAtomicity_EmptyReasonsFallsThroughTo500(t *testing.T) {+ mock := newAtomicityMock(&types.TransactionCanceledException{})+ 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})+ require.Error(t, err)+ assert.NotErrorIs(t, err, ErrPricingConcurrentWrite,+ "empty Reasons[] must not masquerade as a concurrent_open_ended_write")+ assert.NotErrorIs(t, err, ErrPricingUUIDCollision,+ "empty Reasons[] must not masquerade as a uuid_collision")+}++// Case 5: PutPricing of a new open-ended period — sentinel race+// (openEndedId != null at transaction time).+func TestPricingAtomicity_PutOpenEnded_SentinelRace(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(0, 2, "ConditionalCheckFailed")))+ store := NewDynamoPricingStore(mock, pricingTestTable())++ err := store.PutPricing(context.Background(),+ PricingItem{PricingID: "new-open", StartDate: "2026-07-01", PeakRate: 0.3}, nil)+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingConcurrentWrite)+}++// Case 6: UpdatePricing closed → open — sentinel race.+func TestPricingAtomicity_UpdateClosedToOpen_SentinelRace(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(0, 2, "ConditionalCheckFailed")))+ store := NewDynamoPricingStore(mock, pricingTestTable())++ // 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)+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingConcurrentWrite)+}++// Case 7: UpdatePricing open → closed — sentinel race.+func TestPricingAtomicity_UpdateOpenToClosed_SentinelRace(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(0, 2, "ConditionalCheckFailed")))+ store := NewDynamoPricingStore(mock, pricingTestTable())++ end := "2026-06-30"+ openID := "p-1"+ // 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)+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingConcurrentWrite)+}++// Case 8: UpdatePricing open → open (rate-only edit on the open-ended+// row) — sentinel race because a concurrent writer flipped the sentinel.+func TestPricingAtomicity_UpdateOpenToOpen_SentinelRace(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(0, 2, "ConditionalCheckFailed")))+ store := NewDynamoPricingStore(mock, pricingTestTable())++ openID := "p-1"+ err := store.UpdatePricing(context.Background(),+ PricingItem{PricingID: openID, StartDate: "2026-01-01", PeakRate: 0.3}, &openID)+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingConcurrentWrite)+}++// Case 9: DeletePricing of the open-ended row — sentinel race.+func TestPricingAtomicity_DeleteOpenEnded_SentinelRace(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(0, 2, "ConditionalCheckFailed")))+ store := NewDynamoPricingStore(mock, pricingTestTable())++ openID := "p-1"+ err := store.DeletePricing(context.Background(), openID, &openID)+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingConcurrentWrite)+}++// Case 10: first-write sentinel-creation race — both writers observe+// GetSentinel == nil, both submit a transaction with+// attribute_not_exists(pricingId); one wins, the other gets HTTP 409.+func TestPricingAtomicity_FirstWriteSentinelCreationRace(t *testing.T) {+ mock := newAtomicityMock(canceledWith(reasonAt(0, 2, "ConditionalCheckFailed")))+ store := NewDynamoPricingStore(mock, pricingTestTable())++ // prevOpenEndedID is nil for both callers; the production+ // sentinelUpdate uses the WasNull condition expression, but the+ // 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)+ require.Error(t, err)+ assert.ErrorIs(t, err, ErrPricingConcurrentWrite)+}++// A non-cancellation transaction error (network, throttle) must wrap as+// a storage error and NOT masquerade as a typed concurrency miss.+func TestPricingAtomicity_NonCancellationErrorWraps(t *testing.T) {+ mock := newAtomicityMock(errors.New("provisioned throughput exceeded"))+ store := NewDynamoPricingStore(mock, pricingTestTable())++ openID := "p-1"+ err := store.DeletePricing(context.Background(), openID, &openID)+ require.Error(t, err)+ assert.NotErrorIs(t, err, ErrPricingConcurrentWrite)+ assert.NotErrorIs(t, err, ErrPricingUUIDCollision)+ assert.Contains(t, err.Error(), "provisioned throughput exceeded")+}
diff --git a/internal/dynamo/pricing_test.go b/internal/dynamo/pricing_test.gonew file mode 100644index 0000000..4030cae--- /dev/null+++ b/internal/dynamo/pricing_test.go@@ -0,0 +1,421 @@+package dynamo++import (+ "context"+ "encoding/json"+ "errors"+ "sort"+ "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"+)++// TestPricingItemJSONWireShape pins the on-the-wire JSON keys the Swift+// 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",+ }+ 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, "pricingId")+ 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",+ }+ for key, want := range expected {+ assert.Equal(t, want, raw[key], "wire shape key %q", key)+ }+ 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",+ }+ 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")+}++// 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+// by pricingId so reads and writes stay coherent across all paths.+type inMemoryPricingAPI struct {+ items map[string]map[string]types.AttributeValue++ // failTransact toggles a forced TransactionCanceledException with the+ // configured Reasons slice. Used by the atomicity tests.+ failTransact bool+ transactErr error+}++func newInMemoryPricingAPI() *inMemoryPricingAPI {+ return &inMemoryPricingAPI{items: make(map[string]map[string]types.AttributeValue)}+}++func (m *inMemoryPricingAPI) PutItem(_ context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+ id := params.Item["pricingId"].(*types.AttributeValueMemberS).Value+ m.items[id] = params.Item+ return &dynamodb.PutItemOutput{}, nil+}++func (m *inMemoryPricingAPI) GetItem(_ context.Context, params *dynamodb.GetItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) {+ id := params.Key["pricingId"].(*types.AttributeValueMemberS).Value+ if av, ok := m.items[id]; ok {+ return &dynamodb.GetItemOutput{Item: av}, nil+ }+ return &dynamodb.GetItemOutput{}, nil+}++func (m *inMemoryPricingAPI) UpdateItem(_ context.Context, params *dynamodb.UpdateItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error) {+ id := params.Key["pricingId"].(*types.AttributeValueMemberS).Value+ row, ok := m.items[id]+ if !ok {+ // Apply the update by setting fields as if the row existed empty+ // (sufficient for sentinel lazy-create semantics in tests).+ row = map[string]types.AttributeValue{+ "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" {+ continue+ }+ row[name] = v+ }+ m.items[id] = row+ return &dynamodb.UpdateItemOutput{}, nil+}++func (m *inMemoryPricingAPI) DeleteItem(_ context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+ id := params.Key["pricingId"].(*types.AttributeValueMemberS).Value+ delete(m.items, id)+ return &dynamodb.DeleteItemOutput{}, nil+}++func (m *inMemoryPricingAPI) Scan(_ context.Context, params *dynamodb.ScanInput, _ ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) {+ out := make([]map[string]types.AttributeValue, 0, len(m.items))+ for _, av := range m.items {+ out = append(out, av)+ }+ // Stable order so tests can assert deterministically (the production+ // reader sorts by StartDate regardless).+ sort.Slice(out, func(i, j int) bool {+ return out[i]["pricingId"].(*types.AttributeValueMemberS).Value <+ out[j]["pricingId"].(*types.AttributeValueMemberS).Value+ })+ return &dynamodb.ScanOutput{Items: out}, nil+}++func (m *inMemoryPricingAPI) TransactWriteItems(_ context.Context, params *dynamodb.TransactWriteItemsInput, _ ...func(*dynamodb.Options)) (*dynamodb.TransactWriteItemsOutput, error) {+ if m.failTransact {+ if m.transactErr != nil {+ return nil, m.transactErr+ }+ return nil, errors.New("forced transact failure")+ }+ // Apply each item in order. Simple semantics: ConditionExpression is+ // not actually evaluated — the atomicity tests inject failures via+ // failTransact + transactErr to exercise the real handler mapping.+ for _, item := range params.TransactItems {+ switch {+ case item.Put != nil:+ id := item.Put.Item["pricingId"].(*types.AttributeValueMemberS).Value+ m.items[id] = item.Put.Item+ case item.Update != nil:+ id := item.Update.Key["pricingId"].(*types.AttributeValueMemberS).Value+ row, ok := m.items[id]+ if !ok {+ row = map[string]types.AttributeValue{+ "pricingId": &types.AttributeValueMemberS{Value: id},+ }+ }+ for k, v := range item.Update.ExpressionAttributeValues {+ name := k[1:]+ if name == "null" {+ continue+ }+ row[name] = v+ }+ m.items[id] = row+ case item.Delete != nil:+ id := item.Delete.Key["pricingId"].(*types.AttributeValueMemberS).Value+ delete(m.items, id)+ }+ }+ return &dynamodb.TransactWriteItemsOutput{}, nil+}++func pricingTestTable() string { return "test-pricing" }++func readPricing(t *testing.T, api *inMemoryPricingAPI, id string) *PricingItem {+ t.Helper()+ av, ok := api.items[id]+ if !ok {+ return nil+ }+ var item PricingItem+ require.NoError(t, attributevalue.UnmarshalMap(av, &item))+ return &item+}++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",+ }+ require.NoError(t, store.PutPricing(context.Background(), want, nil))++ got, err := store.GetPricing(context.Background(), want.PricingID)+ require.NoError(t, err)+ require.NotNil(t, got)+ assert.Equal(t, want, *got)+}++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",+ }+ require.NoError(t, store.PutPricing(context.Background(), item, nil))+ require.NoError(t, store.DeletePricing(context.Background(), item.PricingID, nil))++ got, err := store.GetPricing(context.Background(), item.PricingID)+ require.NoError(t, err)+ assert.Nil(t, got, "delete should clear the row")+}++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"},+ }+ for _, r := range rows {+ require.NoError(t, store.PutPricing(context.Background(), r, nil))+ }++ got, err := store.ListPricing(context.Background())+ require.NoError(t, err)+ require.Len(t, got, 3, "every closed and open-ended period must appear in the list")+ assert.Equal(t, []string{"p-a", "p-b", "p-c"},+ []string{got[0].PricingID, got[1].PricingID, got[2].PricingID},+ "AC 2.5: ListPricing must sort by startDate ascending")+}++func TestPricingStore_ListPricingExcludesSentinelRow(t *testing.T) {+ api := newInMemoryPricingAPI()+ store := NewDynamoPricingStore(api, pricingTestTable())++ // Seed both a real pricing row and the singleton sentinel directly.+ openID := "pricing-1"+ 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++ 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",+ }+ require.NoError(t, store.PutPricing(context.Background(), item, nil))++ got, err := store.ListPricing(context.Background())+ require.NoError(t, err)+ require.Len(t, got, 1, "the sentinel row must not appear in ListPricing output")+ assert.Equal(t, openID, got[0].PricingID)+}++func TestPricingStore_GetSentinelReturnsNilWhenAbsent(t *testing.T) {+ api := newInMemoryPricingAPI()+ store := NewDynamoPricingStore(api, pricingTestTable())++ got, err := store.GetSentinel(context.Background())+ require.NoError(t, err)+ assert.Nil(t, got, "GetSentinel must return nil when the sentinel row has not been provisioned yet")+}++func TestPricingStore_GetSentinelRoundTrip(t *testing.T) {+ api := newInMemoryPricingAPI()+ store := NewDynamoPricingStore(api, pricingTestTable())++ openID := "pricing-open"+ want := PricingSentinel{+ PricingID: pricingSentinelID,+ OpenEndedID: &openID,+ UpdatedAt: "2026-05-23T10:00:00Z",+ }+ av, err := attributevalue.MarshalMap(want)+ require.NoError(t, err)+ api.items[pricingSentinelID] = av++ got, err := store.GetSentinel(context.Background())+ require.NoError(t, err)+ require.NotNil(t, got)+ assert.Equal(t, want, *got)+}++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",+ }+ 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.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, "2026-05-24T10:00:00Z", got.UpdatedAt)+ assert.Equal(t, original.CreatedAt, got.CreatedAt, "createdAt must not change on update")+}++func TestPricingStore_PutPricingWrapsError(t *testing.T) {+ mock := &mockPricingAPI{+ putItemFn: func(_ context.Context, _ *dynamodb.PutItemInput) (*dynamodb.PutItemOutput, error) {+ return nil, errors.New("throttled")+ },+ }+ store := NewDynamoPricingStore(mock, pricingTestTable())++ end := "2026-12-31"+ err := store.PutPricing(context.Background(), PricingItem{PricingID: "p-1", StartDate: "2026-01-01", EndDate: &end}, nil)+ require.Error(t, err)+ assert.Contains(t, err.Error(), "put pricing")+ assert.Contains(t, err.Error(), "test-pricing")+}++// mockPricingAPI is a function-based double covering every pricing-store+// operation. Fields are typed to match the production interface so error+// paths and per-call behaviour can be stubbed independently.+type mockPricingAPI struct {+ putItemFn func(ctx context.Context, params *dynamodb.PutItemInput) (*dynamodb.PutItemOutput, error)+ getItemFn func(ctx context.Context, params *dynamodb.GetItemInput) (*dynamodb.GetItemOutput, error)+ updateItemFn func(ctx context.Context, params *dynamodb.UpdateItemInput) (*dynamodb.UpdateItemOutput, error)+ deleteItemFn func(ctx context.Context, params *dynamodb.DeleteItemInput) (*dynamodb.DeleteItemOutput, error)+ scanFn func(ctx context.Context, params *dynamodb.ScanInput) (*dynamodb.ScanOutput, error)+ transactWriteFn func(ctx context.Context, params *dynamodb.TransactWriteItemsInput) (*dynamodb.TransactWriteItemsOutput, error)+}++func (m *mockPricingAPI) PutItem(ctx context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+ if m.putItemFn != nil {+ return m.putItemFn(ctx, params)+ }+ return &dynamodb.PutItemOutput{}, nil+}++func (m *mockPricingAPI) GetItem(ctx context.Context, params *dynamodb.GetItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) {+ if m.getItemFn != nil {+ return m.getItemFn(ctx, params)+ }+ return &dynamodb.GetItemOutput{}, nil+}++func (m *mockPricingAPI) UpdateItem(ctx context.Context, params *dynamodb.UpdateItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error) {+ if m.updateItemFn != nil {+ return m.updateItemFn(ctx, params)+ }+ return &dynamodb.UpdateItemOutput{}, nil+}++func (m *mockPricingAPI) DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+ if m.deleteItemFn != nil {+ return m.deleteItemFn(ctx, params)+ }+ return &dynamodb.DeleteItemOutput{}, nil+}++func (m *mockPricingAPI) Scan(ctx context.Context, params *dynamodb.ScanInput, _ ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) {+ if m.scanFn != nil {+ return m.scanFn(ctx, params)+ }+ return &dynamodb.ScanOutput{}, nil+}++func (m *mockPricingAPI) TransactWriteItems(ctx context.Context, params *dynamodb.TransactWriteItemsInput, _ ...func(*dynamodb.Options)) (*dynamodb.TransactWriteItemsOutput, error) {+ if m.transactWriteFn != nil {+ return m.transactWriteFn(ctx, params)+ }+ return &dynamodb.TransactWriteItemsOutput{}, nil+}
diff --git a/internal/dynamo/pricing_transactional.go b/internal/dynamo/pricing_transactional.gonew file mode 100644index 0000000..1cd8225--- /dev/null+++ b/internal/dynamo/pricing_transactional.go@@ -0,0 +1,276 @@+package dynamo++import (+ "context"+ "errors"+ "fmt"+ "time"++ "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"+)++// ErrPricingConcurrentWrite is returned when a sentinel-touching+// transaction is cancelled because another writer flipped the sentinel+// (or the closing row's open-ended state) between the validator scan+// and the transaction. The API handler maps this to HTTP 409+// concurrent_open_ended_write.+var ErrPricingConcurrentWrite = errors.New("pricing: concurrent open-ended write")++// ErrPricingUUIDCollision is returned when a new-row insertion's+// attribute_not_exists(pricingId) ConditionCheck fires. Indicates a UUID+// collision; the handler maps this to HTTP 500 internal_error so the+// caller retries with a fresh UUID.+var ErrPricingUUIDCollision = errors.New("pricing: uuid collision on new row")++// 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+// writers on every subsequent write (Decision 21).+const sentinelConditionExpression = "attribute_not_exists(pricingId) OR openEndedId = :prevOpenEndedID"++// sentinelConditionExpressionWasNull is used when the previous sentinel+// value was nil — DynamoDB cannot equality-compare an absent attribute,+// so we assert attribute_not_exists(openEndedId) instead.+const sentinelConditionExpressionWasNull = "attribute_not_exists(pricingId) OR attribute_not_exists(openEndedId)"++// sentinelUpdate constructs the sentinel-row Update inside a+// TransactWriteItems request. newOpenEndedID is the value to write+// (nil clears the attribute); prevOpenEndedID is the value asserted on+// the row before the write.+func (s *DynamoPricingStore) sentinelUpdate(newOpenEndedID, prevOpenEndedID *string, now string) types.TransactWriteItem {+ expr := "SET openEndedId = :newOpenEndedID, updatedAt = :updatedAt"+ if newOpenEndedID == nil {+ expr = "REMOVE openEndedId SET updatedAt = :updatedAt"+ }++ values := map[string]types.AttributeValue{+ ":updatedAt": &types.AttributeValueMemberS{Value: now},+ }+ if newOpenEndedID != nil {+ values[":newOpenEndedID"] = &types.AttributeValueMemberS{Value: *newOpenEndedID}+ }++ cond := sentinelConditionExpressionWasNull+ if prevOpenEndedID != nil {+ cond = sentinelConditionExpression+ values[":prevOpenEndedID"] = &types.AttributeValueMemberS{Value: *prevOpenEndedID}+ }++ return types.TransactWriteItem{+ Update: &types.Update{+ TableName: &s.table,+ Key: pricingKey(pricingSentinelID),+ UpdateExpression: &expr,+ ConditionExpression: &cond,+ ExpressionAttributeValues: values,+ },+ }+}++// rowPutTransactItem builds the Put portion of a pricing-row transact+// write with a uniqueness guard so a UUID collision surfaces as a+// distinct error code at the index-2 position.+func (s *DynamoPricingStore) rowPutTransactItem(item PricingItem) (types.TransactWriteItem, error) {+ av, err := attributevalue.MarshalMap(item)+ if err != nil {+ return types.TransactWriteItem{}, fmt.Errorf("marshal pricing (pricingId=%s): %w", item.PricingID, err)+ }+ cond := "attribute_not_exists(pricingId)"+ return types.TransactWriteItem{+ Put: &types.Put{+ TableName: &s.table,+ Item: av,+ ConditionExpression: &cond,+ },+ }, nil+}++// putOpenEndedPeriod inserts a new open-ended pricing row inside a+// TransactWriteItems request, co-maintaining the sentinel row.+func (s *DynamoPricingStore) putOpenEndedPeriod(ctx context.Context, item PricingItem, prevOpenEndedID *string) error {+ rowItem, err := s.rowPutTransactItem(item)+ if err != nil {+ return err+ }+ now := nowRFC3339()+ out := s.sentinelUpdate(&item.PricingID, prevOpenEndedID, now)+ _, err = s.client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{+ TransactItems: []types.TransactWriteItem{out, rowItem},+ })+ if err != nil {+ return mapTransactionError(err, []reasonHandler{+ // Sentinel race or stale prevOpenEndedID.+ conditionFailedAs(ErrPricingConcurrentWrite),+ // New row UUID collision.+ conditionFailedAs(ErrPricingUUIDCollision),+ }, fmt.Sprintf("put open-ended pricing (table=%s, pricingId=%s)", s.table, item.PricingID))+ }+ return nil+}++// updateOpenEndedTransition handles the three sub-cases that touch the+// open-ended sentinel during an update: closed→open, open→closed, and+// open→open (rate-only edit on the open-ended row). All three issue a+// two-item TransactWriteItems request (sentinel update + row update).+func (s *DynamoPricingStore) updateOpenEndedTransition(ctx context.Context, item PricingItem, prevOpenEndedID *string, isOpen bool) error {+ av, err := attributevalue.MarshalMap(item)+ if err != nil {+ return fmt.Errorf("marshal pricing (pricingId=%s): %w", item.PricingID, err)+ }+ // Row Put with attribute_exists(pricingId) so a deleted-then-updated+ // race surfaces as a transaction-cancel, not a silent re-creation.+ rowCond := "attribute_exists(pricingId)"+ rowItem := types.TransactWriteItem{+ Put: &types.Put{+ TableName: &s.table,+ Item: av,+ ConditionExpression: &rowCond,+ },+ }++ var newOpenEndedID *string+ if isOpen {+ newOpenEndedID = &item.PricingID+ }+ now := nowRFC3339()+ sentinel := s.sentinelUpdate(newOpenEndedID, prevOpenEndedID, now)++ _, err = s.client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{+ TransactItems: []types.TransactWriteItem{sentinel, rowItem},+ })+ if err != nil {+ return mapTransactionError(err, []reasonHandler{+ conditionFailedAs(ErrPricingConcurrentWrite),+ conditionFailedAs(ErrPricingConcurrentWrite),+ }, fmt.Sprintf("update pricing transition (table=%s, pricingId=%s)", s.table, item.PricingID))+ }+ return nil+}++// deleteOpenEndedPeriod removes the open-ended pricing row inside a+// TransactWriteItems request, co-maintaining the sentinel row.+func (s *DynamoPricingStore) deleteOpenEndedPeriod(ctx context.Context, id string, prevOpenEndedID *string) error {+ now := nowRFC3339()+ sentinel := s.sentinelUpdate(nil, prevOpenEndedID, now)+ rowCond := "attribute_exists(pricingId)"+ row := types.TransactWriteItem{+ Delete: &types.Delete{+ TableName: &s.table,+ Key: pricingKey(id),+ ConditionExpression: &rowCond,+ },+ }+ _, err := s.client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{+ TransactItems: []types.TransactWriteItem{sentinel, row},+ })+ if err != nil {+ return mapTransactionError(err, []reasonHandler{+ conditionFailedAs(ErrPricingConcurrentWrite),+ conditionFailedAs(ErrPricingConcurrentWrite),+ }, fmt.Sprintf("delete open-ended pricing (table=%s, pricingId=%s)", s.table, id))+ }+ return nil+}++// ReplaceOpenEnded atomically closes the existing open-ended row at+// closingEndDate and inserts newItem. The sentinel is updated to+// 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.+func (s *DynamoPricingStore) ReplaceOpenEnded(ctx context.Context, closingID string, closingEndDate string, updatedAt string, newItem PricingItem) error {+ prevOpenEndedID := &closingID+ var newOpenEndedID *string+ if newItem.EndDate == nil {+ newOpenEndedID = &newItem.PricingID+ }++ // updatedAt is supplied by the caller so the handler's synthesised+ // response carries the same timestamp DynamoDB persists. Without this+ // the handler's response would diverge from the next GET /pricing+ // read by however long the two time.Now() calls drifted.+ sentinel := s.sentinelUpdate(newOpenEndedID, prevOpenEndedID, updatedAt)++ // Closing-row Update: set the end date, bump updatedAt, but only if+ // the row is still open-ended (attribute_not_exists(endDate)) and+ // still pointing at the supplied id. The attribute_not_exists check+ // catches a concurrent close.+ closingExpr := "SET endDate = :end, updatedAt = :updatedAt"+ closingCond := "attribute_not_exists(endDate) AND pricingId = :closingId"+ closing := types.TransactWriteItem{+ Update: &types.Update{+ TableName: &s.table,+ Key: pricingKey(closingID),+ UpdateExpression: &closingExpr,+ ExpressionAttributeValues: map[string]types.AttributeValue{+ ":end": &types.AttributeValueMemberS{Value: closingEndDate},+ ":updatedAt": &types.AttributeValueMemberS{Value: updatedAt},+ ":closingId": &types.AttributeValueMemberS{Value: closingID},+ },+ ConditionExpression: &closingCond,+ },+ }++ newRow, err := s.rowPutTransactItem(newItem)+ if err != nil {+ return err+ }++ _, err = s.client.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{+ TransactItems: []types.TransactWriteItem{sentinel, closing, newRow},+ })+ if err != nil {+ return mapTransactionError(err, []reasonHandler{+ conditionFailedAs(ErrPricingConcurrentWrite), // sentinel+ conditionFailedAs(ErrPricingConcurrentWrite), // closing row+ conditionFailedAs(ErrPricingUUIDCollision), // new row+ }, fmt.Sprintf("replace open-ended pricing (table=%s, closingId=%s, newId=%s)", s.table, closingID, newItem.PricingID))+ }+ 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;+// nil means this position is not the failing one.+type reasonHandler func(reason types.CancellationReason) error++// conditionFailedAs returns a reasonHandler that maps a+// ConditionalCheckFailed entry at this position to the given error.+func conditionFailedAs(err error) reasonHandler {+ return func(reason types.CancellationReason) error {+ if reason.Code != nil && *reason.Code == "ConditionalCheckFailed" {+ return err+ }+ return nil+ }+}++// mapTransactionError converts a TransactionCanceledException into a+// typed error using positional reason handlers. Anything that isn't a+// TransactionCanceledException — or that comes back with an empty+// Reasons slice — falls through as a wrapped storage error so the+// handler logs the raw exception and returns HTTP 500.+func mapTransactionError(err error, handlers []reasonHandler, desc string) error {+ var canceled *types.TransactionCanceledException+ if !errors.As(err, &canceled) {+ return fmt.Errorf("%s: %w", desc, err)+ }+ for i, reason := range canceled.CancellationReasons {+ if i >= len(handlers) {+ break+ }+ if mapped := handlers[i](reason); mapped != nil {+ return mapped+ }+ }+ return fmt.Errorf("%s: %w", desc, err)+}++// nowRFC3339 returns the current time as an RFC3339 UTC string. Kept as+// a helper so atomicity tests can pin the format and the API handler+// reuses the same shape.+func nowRFC3339() string {+ return time.Now().UTC().Format(time.RFC3339)+}
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex a3450a5..ca47634 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -22,6 +22,7 @@ | [Stat Comparisons](#stat-comparisons) | 2026-05-10 | Done | Opt-in Compare toggle on Day Detail with a period chip (Yesterday / 7 days ago) that renders an absolute-kWh delta as a sub-line under each SummaryBlock row and DayInFiveBlocksPanel value, in the same lighter/smaller treatment as the five-block time-range captions. Client-only — comparison day fetched via the existing `/day` endpoint; backend untouched. T-1161. | | [Zoom in on Graphs](#zoom-in-on-graphs) | 2026-05-12 | Done | Tap-to-enlarge affordance on the five active chart cards (History solar / grid usage / daily usage; Day Detail power and battery combined). iOS uses a `fullScreenCover` with a UIKit bridge requesting landscape (orientation lock restored on every dismissal path, portrait fallback if denied). macOS opens a separate `WindowGroup(for: ChartKind)` per chart, identity by view type only — re-expand brings the existing window forward; `.defaultLaunchBehavior(.suppressed)` prevents launch-time restoration; `.windowManagerRole(.associated)` keeps the Window menu honest. Selection interactivity preserved: History reuses `historySelectionOverlay` with a real `HistoryDragGate`; Day Detail uses `XSelectionQuiescenceGate` (400 ms quiet window) since `chartXSelection` has no lifecycle. Day Detail `selectedDate` lifted from the two chart views into `DayDetailView` so inline and enlarged share state. macOS data flows via a per-scene `ChartSceneObserver` (60 s polling) keyed by `ChartScopeRegistry`, not by lifting per-screen view models. iOS sheet state survives backgrounding via `@SceneStorage`. T-1215. | | [SoC Alerts](#soc-alerts) | 2026-05-19 | Done | User-defined battery state-of-charge alerts. Per-device rules pair a percentage threshold with a daily time window (HH:MM device-local, cross-midnight supported); the Go poller evaluates rules each 10s cycle and dispatches APNs pushes via `sideshow/apns2` (entitlements already provisioned). Three new DynamoDB tables (`flux-devices`, `flux-soc-rules`, `flux-soc-fire-state`). Fires once per (rule, local-window-start day) — collapse-id `base64url(SHA256(deviceId|ruleId|date))[:22]` keeps APNs under its 64-byte cap. In-process previous-SoC comparator keyed by `(deviceRule, windowStartDate)` and tagged with rule `UpdatedAt` so Lambda rule edits propagate without IPC. Pushes go through a 64-deep / 4-worker bounded queue so APNs RTT cannot stall the live-poll cadence. Lambda routing migrates to `http.ServeMux` (Go 1.22+ path params). Stable device identifier is a UUID in container `UserDefaults` (not Keychain, not app-group — so reinstall resets). iOS/macOS Settings → Alerts list/add/edit/delete sheet driven by a new `FluxCore/Notifications/` module; `FluxiOSAppDelegate` moves to its own file alongside new APNs callbacks. Orphan device rows (no successful registration in 30 days) are cascade-deleted by the existing midnight pass with conditional delete to dodge the re-registration race. T-1288. |+| [Daily Costs](#daily-costs) | 2026-05-23 | Done | New Day Detail "costs" card (peak imports cost / solar feed-in income / net / off-peak savings) and a new History "Period costs" card with four aggregate tiles. Backed by a single shared tenant-wide pricing configuration: new `flux-pricing` DynamoDB table with a sentinel row enforcing AC 1.9 atomicity via `TransactWriteItems` (first use in the repo); five Lambda CRUD endpoints plus a transactional close-and-create remediation for the editor's overlap flow. Rates stored as Float64 with 4-dp normalisation on write and bounded to [0, $10/kWh]. Pricing dates are `YYYY-MM-DD` strings interpreted in `Australia/Melbourne`. Cost computation is client-side in FluxCore (`DayCosts.costs(forDate:in:)` on `DaySummary`, `PeriodCosts.compute(days:pricing:)`) — no backend enrichment of `/day` or `/history`. `@Observable @MainActor` `PricingService` injects into Day Detail / History view models; refresh policy: on Settings open, Day Detail load, History range change, and after any mutation. Card hides only when the day's aggregate row is missing; zero kWh is a legitimate value. iOS + macOS Settings UI mirrors the SoC Alerts pattern. T-1326. | --- @@ -215,3 +216,12 @@ User-defined battery state-of-charge alerts. Each device manages up to 10 rules; - [prerequisites.md](soc-alerts/prerequisites.md) - [requirements.md](soc-alerts/requirements.md) - [tasks.md](soc-alerts/tasks.md)++## Daily Costs++New Day Detail "costs" card (peak imports cost / solar feed-in income / net / off-peak savings) and a new History "Period costs" card with four aggregate tiles. Backed by a single shared tenant-wide pricing configuration: new `flux-pricing` DynamoDB table with a singleton sentinel row (`pricingId = "__open_ended"`) enforcing AC 1.9 atomicity via `TransactWriteItems` — first use of transactional writes in the repo. Five Lambda CRUD endpoints (`GET/POST/PUT/DELETE /pricing` + `POST /pricing/replace-open-ended`) behind the existing bearer-token auth. Pricing periods carry a required `startDate`, optional `endDate`, and three rates (peak grid, solar feed-in, off-peak savings) in AUD per kWh; rates stored as Float64 with strict 4-dp normalisation on write and bounded to [0, $10/kWh]. Validation order is `inverted_dates → overlap → rate_precision → rate_out_of_range → second_open_ended`; closed-period overlap is best-effort under concurrency, AC 1.9 is fully race-safe via the sentinel. Pricing dates are `YYYY-MM-DD` strings interpreted in `Australia/Melbourne` and day-membership is lexicographic string compare. Cost computation is client-side in FluxCore: `DaySummary.costs(forDate:in:)` (Day Detail), `DayEnergy.costs(in:)` (History), `PeriodCosts.compute(days:pricing:)` — no `/day` or `/history` response changes. When the off-peak split is `nil` for a day, peak imports kWh falls back to `eInput` and off-peak savings is $0.00 (Decision 23). A day is "priced" iff a pricing period covers it AND the daily-energy aggregate row exists; zero kWh values are legitimate (overcast, full-self-consumed). `@Observable @MainActor` `PricingService` injects into `DayDetailViewModel`, `HistoryViewModel`, and the new Settings flow; refreshes on Settings open, Day Detail load, History range change, and after any mutation (fold response + fire-and-forget refetch). iOS + macOS Settings UI mirrors the SoC Alerts pattern (list, editor sheet, swipe-to-delete) with a one-tap overlap-remediation button that invokes the atomic close-and-create endpoint. History Period Overview gets a separate "Period costs" card below the existing 8-tile overview rather than extending it, so the partial-coverage caption ("N of M days priced") has a natural home and the card disappears cleanly when no pricing exists. Out of scope: per-user pricing, multi-currency, shoulder/peak-window tariff bucket beyond the existing off-peak SSM window, frozen cost snapshots, cost figures on Dashboard / widgets / Control Center / What's New, CSV/PDF export. T-1326.++- [decision_log.md](daily-costs/decision_log.md)+- [design.md](daily-costs/design.md)+- [requirements.md](daily-costs/requirements.md)+- [tasks.md](daily-costs/tasks.md)
diff --git a/specs/daily-costs/decision_log.md b/specs/daily-costs/decision_log.mdnew file mode 100644index 0000000..a79dc6d--- /dev/null+++ b/specs/daily-costs/decision_log.md@@ -0,0 +1,786 @@+# Decision Log: Daily Costs++## Decision 1: Sizing — full spec workflow++**Date**: 2026-05-23+**Status**: accepted++### Context++T-1326 introduces a new costs/income table on Day Detail (and History totals), backed by user-configured per-kWh rates stored in the cloud. The scope-assessment phase had to pick between the lightweight smolspec workflow and the full requirements/design/tasks workflow.++### Decision++Use the full spec workflow (requirements → design → tasks → branch).++### Rationale++The change touches multiple subsystems: a new DynamoDB table, four new CRUD endpoints on the Lambda, FluxCore data models, iOS Settings UI, macOS Settings scene, Day Detail UI, and History Period Overview UI. Estimated implementation is ~800–1500 LOC across ~15–25 files. Multiple ambiguities in the brief (surface, tenancy, currency, off-peak math, gaps, overlap, retroactivity) needed structured clarification.++### Alternatives Considered++- **Smolspec**: Lightweight path used for ≤80 LOC / ≤3 files. Rejected — scope is well above that threshold and the brief is too ambiguous.++### Consequences++**Positive:**+- Each subsystem (backend, FluxCore, iOS, macOS) gets explicit acceptance criteria before code is touched.+- The 7 open questions get resolved on paper, not in PR review.++**Negative:**+- More upfront work before any code lands.++---++## Decision 2: Costs surface — Day Detail card + History period totals++**Date**: 2026-05-23+**Status**: accepted++### Context++The brief said "detail/history view" without specifying whether History meant per-day rows or aggregate totals. Three placements were viable: Day Detail only, Day Detail + History period totals, or Day Detail + per-day mini-row on History.++### Decision++Render the four-value table on Day Detail and a four-tile aggregate on the History Period Overview. No per-day cost figures on History rows.++### Rationale++Period totals match how the user already consumes History (8 tile overview added in T-896). Per-day cost figures on each row would crowd a card that already renders five-block stacked bars. Day Detail is the obvious place for the per-day breakdown.++### Alternatives Considered++- **Day Detail only**: Less information, but consistent with the most conservative reading of the brief — rejected because period costs are a natural extension once the data exists.+- **Per-day mini-row on History**: Maximum information but a heavier UI change with diminishing returns — rejected.++### Consequences++**Positive:**+- Period totals fall naturally out of the existing Period Overview KPI card layout.+- Day Detail card is self-contained.++**Negative:**+- Two render paths to keep in sync rather than one.++---++## Decision 3: Tenancy — single shared pricing set++**Date**: 2026-05-23+**Status**: accepted++### Context++The brief said "stored in the cloud so we don't need to set it up for each user", implying tenant-wide. Flux has no user identity in the API today — auth is a single shared bearer token.++### Decision++Pricing periods are tenant-wide. There is no per-user dimension on the data or the endpoints.++### Rationale++Matches the brief verbatim. Matches existing Day Notes (single shared note per date) and the existing auth model. Avoids adding user identity for a two-user system.++### Alternatives Considered++- **Per-user / per-device**: Would require introducing user identity to the API. Rejected — overkill for a two-user setup and the brief explicitly argues against it.++### Consequences++**Positive:**+- No auth model changes.+- Simple data model.++**Negative:**+- If the second user has a different electricity contract, that is not modellable. Acceptable for a household system.++---++## Decision 4: Currency — fixed AUD++**Date**: 2026-05-23+**Status**: accepted++### Context++Flux is a personal Melbourne system. The brief didn't name a currency.++### Decision++All rates are entered and displayed in AUD with the `$` symbol and two-decimal formatting (e.g. "$3.42").++### Rationale++The system is single-tenant single-location. No locale routing or multi-currency benefit at this scale.++### Alternatives Considered++- **Locale-driven**: Use the device locale's currency. Rejected — adds formatter / parser surface for zero benefit.++### Consequences++**Positive:**+- No locale plumbing.++**Negative:**+- Anyone deploying Flux outside Australia would have to patch the formatter. Acceptable.++---++## Decision 5: Off-peak savings formula++**Date**: 2026-05-23+**Status**: accepted++### Context++The brief listed three rates including "Off-peak savings per kWh" and asked for an "Off-peak savings" line in the table. The third rate name already encodes "savings per kWh", so the formula was ambiguous.++### Decision++Off-peak savings = off-peak window grid-import kWh × off-peak savings rate.++### Rationale++User confirmation: "its a calculation based on how much we've saved by using the offpeak window. usage * value." The "off-peak savings per kWh" rate is the saving figure per kWh consumed during the off-peak window — not a separate cheaper energy rate.++### Alternatives Considered++- **Savings vs peak rate**: offPeakKwh × (peakRate − offPeakRate). Rejected — doesn't match the user's stated intent and doesn't match how the third rate is labelled.+- **Both shown side-by-side**: More information, but rejected — user wanted a single savings figure.++### Consequences++**Positive:**+- Formula is trivial and matches the rate's literal name.++**Negative:**+- The figure expresses the user's stated savings model, which is not the same as an econometric "vs counterfactual" calculation. Acceptable since it's the user's call.++---++## Decision 6: Gap handling — omit cost figures entirely++**Date**: 2026-05-23+**Status**: accepted++### Context++Days that fall outside every configured pricing period need a defined behaviour: show em-dashes, fall back to the most recent rate, or hide the card.++### Decision++Days with no covering pricing period render no costs card at all (Day Detail), and are excluded from History period totals. History totals show "N of M days priced" caption when coverage is partial.++### Rationale++Matches the brief's "if no pricing is set, nothing should be shown" — extended uniformly to per-day coverage. Avoids quietly synthesising fake numbers via fallback rates, which would mask configuration mistakes.++### Alternatives Considered++- **Em-dashes**: Card stays visible with em-dashed cells. Rejected — same screen real estate cost, less consistent with the brief.+- **Most-recent-rate fallback**: Hide the gap from the user. Rejected — confidently wrong numbers are worse than absent numbers.++### Consequences++**Positive:**+- Empty-state behaviour is consistent across "no pricing at all", "this specific day uncovered", and "History range partially uncovered".++**Negative:**+- Partial-coverage period totals are easy to misread as "this is the cost across the whole range". The caption mitigates but doesn't eliminate the risk.++---++## Decision 7: Overlap rules — reject on save++**Date**: 2026-05-23+**Status**: accepted++### Context++Two adjacent pricing periods might overlap by a day or more if the user enters them inattentively. Three options were considered: reject, auto-truncate, or allow and resolve at compute time.++### Decision++Reject any new or updated period whose date range overlaps an existing period. The validation runs server-side, returns HTTP 400, and surfaces in the editor sheet.++### Rationale++Keeps the data model simple — every day in the database falls into at most one pricing period. Auto-truncation would silently mutate older data, and runtime-resolution would require an arbitrary tiebreaker.++### Alternatives Considered++- **Newer-period-wins truncation**: Auto-update the older period's end date. Rejected — silent mutation of historic config is surprising.+- **Latest-created wins at compute time**: Allow overlap, pick the most recently created. Rejected — invisible behaviour.++### Consequences++**Positive:**+- Single source of truth for every day's pricing.+- Editor errors are explicit.++**Negative:**+- Users must close the previous period (set its end date) before adding a new one. The editor offers a one-tap remediation (AC 3.6) so the workflow stays single-step in the common case.++---++## Decision 8: Backfill — retroactive recomputation++**Date**: 2026-05-23+**Status**: accepted++### Context++When pricing is added or changed after data has accrued, two policies are possible: rewrite historical figures with the new pricing, or freeze each day's cost figures at first-compute time.++### Decision++Cost figures are derived on read from current pricing. No per-day cost snapshot is persisted.++### Rationale++Matches the brief's "set for a specific period (starting date and optional end date)" — the user is configuring rates per date range, not stamping immutable invoices. Editing a rate to fix a mistake should fix every affected day. Storage stays minimal.++### Alternatives Considered++- **Snapshot at compute time**: Persist each day's cost figures. Rejected — costs extra storage and produces a surprising "I changed the rate but the old number sticks" experience.++### Consequences++**Positive:**+- No backfill job needed when rates change.+- Cost figures always reflect the current truth.++**Negative:**+- Recomputation on every render. Trivial work for 30 days × 3 multiplications, so not a concern.++---++## Decision 9: Peak rate covers all non-off-peak imports++**Date**: 2026-05-23+**Status**: accepted++### Context++Design-critic flagged that the brief's "costs of the peak-imports" line is ambiguous: AU electricity contracts commonly carry three tariff bands (peak / shoulder / off-peak), but Flux currently only models two (the SSM `/flux/offpeak/*` window and "everything else"). Without a decision, AC 4.2's math is unspecified.++### Decision++The peak grid rate applies to every grid-import kWh that falls outside the SSM off-peak window — i.e. the existing "peak imports" figure already used on History Usage Stats. There is no separate shoulder band.++### Rationale++Matches the existing two-band data model. Avoids a backend schema change. The user can always close one period and start another on the same date if their real-world contract introduces a more granular tariff later. The off-peak savings line still captures the "off-peak window is cheaper" effect via the off-peak savings rate.++### Alternatives Considered++- **Three-band tariff (peak / shoulder / off-peak)**: Closer to real AU contracts. Rejected — adds a tariff-time-of-day schema before there's a stated need.+- **User-defined "peak window" per pricing period**: Most flexible. Rejected — large UI surface for a one-user system.++### Consequences++**Positive:**+- AC 4.2 math is unambiguous and uses an already-computed figure.++**Negative:**+- Households with a shoulder tariff will see slightly overstated peak-imports cost. Acceptable since the user controls the rate.++---++## Decision 10: Rate precision — store 4 dp, display rates at 4 dp, totals at 2 dp++**Date**: 2026-05-23+**Status**: accepted++### Context++Design-critic noted that AU electricity rates routinely run to 4 decimal places (e.g. `$0.2873/kWh`); storing or displaying rates at two decimals would lose meaningful precision when reconciling against a bill. Daily / period totals don't need the same precision — they're cents-level numbers.++### Decision++Rates are stored as decimals with up to 4 places, displayed at 4 places in Settings (e.g. "$0.2873 / kWh"), and used at full precision when computing monetary totals. Monetary totals are displayed rounded to 2 decimal places (e.g. "$3.42").++### Rationale++Two-decimal *display* of a total is what users see on their bill; four-decimal *display* of a rate is what they enter from their bill. Storage matches display granularity for rates so round-tripping through Settings is lossless.++### Alternatives Considered++- **Two-decimal storage for rates**: Simpler. Rejected — loses precision against real-world tariffs.+- **Integer cents storage**: Avoids floating-point pitfalls. Rejected — rates aren't integer cents; the four-decimal precision is the dominant constraint.++### Consequences++**Positive:**+- Reconcilable against an AU electricity bill.++**Negative:**+- Computation needs to use a precise decimal type or accept Float64 rounding. Float64 rounding errors at four-decimal precision over ~30 days are well below `$0.01` — acceptable.++---++## Decision 11: Idempotent delete — always 404 on unknown id++**Date**: 2026-05-23+**Status**: accepted++### Context++The initial draft said delete "SHALL succeed idempotently for already-deleted ids only on the immediate retry" — design-critic correctly noted that's not implementable without request deduplication infrastructure that doesn't exist.++### Decision++`DELETE /pricing/{id}` returns HTTP 404 when the id is unknown. There is no idempotency claim beyond what HTTP already provides.++### Rationale++Matches the existing SoC Alerts delete behaviour. Simpler to reason about; clients that need at-least-once delivery can retry on transport errors but treat 404 as terminal success.++### Alternatives Considered++- **Always 204 (no content) regardless of existence**: Idempotent in the strict sense. Rejected — masks "is this the id I think it is?" mistakes.++### Consequences++**Positive:**+- Behaviour is well-defined and matches SoC Alerts.++**Negative:**+- Concurrent deletes from two devices: the second device sees 404. Acceptable; the UI refreshes the list anyway.++---++## Decision 12: Rate sanity-cap at $10/kWh++**Date**: 2026-05-23+**Status**: accepted++### Context++Negative rates were already rejected (Decision draft AC 1.5). Design-critic noted a typo could enter `$28.73` or `$287.3` instead of `$0.2873`, producing wildly wrong cost figures. AU electricity rates max out around $0.50–0.60/kWh in practice.++### Decision++Each of the three rate fields is rejected if > 10.0 AUD per kWh.++### Rationale++10× the highest plausible AU retail tariff. Catches typos by an order of magnitude without constraining legitimate use.++### Alternatives Considered++- **No cap**: Smaller surface area. Rejected — the failure mode is asymmetric (small mistakes are caught by the user, huge ones produce alarming card numbers).+- **Tighter cap (e.g. $2)**: Closer to plausible reality. Rejected — risks rejecting future legitimate prices.++### Consequences++**Positive:**+- Typos that change rate by an order of magnitude are caught at save.++**Negative:**+- A future hyperinflation scenario or a different deployment would need to raise the cap. Acceptable.++---++## Decision 13: Pricing dates use the SSM off-peak window's timezone++**Date**: 2026-05-23+**Status**: accepted++### Context++Pricing period dates need a defined interpretation: when does "the day 2026-04-12" start and end? Lambda runs in UTC; the off-peak window is configured in local time (Melbourne).++### Decision++Pricing period start and end dates are interpreted in the same timezone as the off-peak SSM window (`Australia/Melbourne`). The codebase documents this assumption alongside the off-peak handling.++### Rationale++Avoids two-timezone reasoning. Matches how the off-peak window already partitions a day. The "off-peak window grid import kWh" figure powering AC 4.4 is computed in local time, so pricing-period boundaries must use the same time basis.++### Alternatives Considered++- **UTC**: Simpler at the storage layer. Rejected — would misattribute days near midnight Melbourne time.+- **User-configurable timezone**: Maximum flexibility. Rejected — over-engineered for a Melbourne-only system.++### Consequences++**Positive:**+- Timezone reasoning is centralised in the off-peak handling code that already exists.++**Negative:**+- A deployment outside Melbourne would need to change one configured timezone in the off-peak handling. Acceptable.++---++## Decision 14: Client-side cost computation in FluxCore++**Date**: 2026-05-23+**Status**: accepted++### Context++Cost figures could be computed server-side (enriching `/day` and `/history` responses) or client-side (FluxCore math over the pricing list plus the existing daily-energy figures). Both satisfy the ACs.++### Decision++Cost computation lives in FluxCore as pure functions over `(DayEnergy, [PricingPeriod])`. The backend exposes pricing CRUD only; `/day` and `/history` response shapes are unchanged.++### Rationale++The math is six multiplications per day. FluxCore can unit-test it without HTTP. Retroactive recomputation (Requirement 6) is automatic on every render because the pricing list is re-fetched per AC 2.7. Cross-device consistency falls out of the same: both devices fetch the same pricing list and compute the same numbers. The Lambda stays unaffected by a feature whose data layer is entirely tangential to its read endpoints.++### Alternatives Considered++- **Server-side enrichment**: Single source of truth on the wire; clients render dumb fields. Rejected — adds a Lambda dependency on the pricing table for every `/day` / `/history` call, complicates caching, and the math isn't worth the round-trip when the client already has the pricing list to drive Settings.++### Consequences++**Positive:**+- Backend stays small (CRUD-only on a new table).+- Cost math is unit-testable as pure functions.+- Retroactivity is free.++**Negative:**+- The math contract lives in FluxCore; if a non-Apple client were ever added it would have to re-implement. Acceptable — there are no other clients.++---++## Decision 15: Atomic close-and-create endpoint for open-ended-period replacement++**Date**: 2026-05-23+**Status**: accepted++### Context++Peer-review-validator flagged that the editor's overlap-remediation flow (close the existing open-ended period, then create the new one) is two sequential mutations. If close succeeds and create fails, the user is left with a silently-closed open-ended period and no replacement.++### Decision++The Lambda API exposes a single transactional endpoint that closes the existing open-ended period at a caller-supplied date AND creates a new pricing period in one request. If either step fails for any reason, neither is persisted. The editor's one-tap remediation invokes this endpoint exclusively.++### Rationale++DynamoDB's `TransactWriteItems` supports atomic multi-item writes. Using a single endpoint keeps the partial-state failure mode out of the system. Failure handling on the client collapses to "retry the same one-tap action."++### Alternatives Considered++- **Two sequential mutations + visible partial state**: Client surfaces "previous period closed, new period failed; please retry" when create fails after close succeeds. Rejected — non-atomic by construction; users can quit the app between the two calls.+- **No remediation flow, just a documented two-step path**: Editor copy explains to close the period first. Rejected — the brief's "set for a specific period" usage pattern would frequently produce overlaps for naive users.++### Consequences++**Positive:**+- Editor flow is single-tap and atomic.+- No "ghost open-ended period closed but unreplaced" state.++**Negative:**+- One extra Lambda route to maintain.++---++## Decision 16: Off-peak split absence is treated as zero, not as "input missing"++**Date**: 2026-05-23+**Status**: accepted++### Context++Peer-review-validator noted that `OffpeakItem` records can be absent or stale on past dates (per `docs/agent-notes/api-layer.md`). The initial AC 4.5 said "if any of the three input kWh figures is unavailable, the card is not rendered" — but absent off-peak split is a common state, not a data-integrity failure, and the card should still render.++### Decision++A "priced day" requires only the peak-import kWh figure and the solar export kWh figure to be present. The off-peak window grid-import kWh figure is treated as `0` when unavailable or when the off-peak SSM window is unset / zero-width. The card still renders; off-peak savings displays "$0.00".++### Rationale++The two figures that materially drive the costs card (peak imports cost, solar feed-in income, net) are the existing daily-aggregate fields that survive the 30-day reading TTL. The off-peak split is a value-add overlay; treating its absence as render-blocking would erase the entire card for any day with a stale off-peak record.++### Alternatives Considered++- **Treat off-peak absence as render-blocking**: Conservative but hides the whole card on stale-but-recoverable state. Rejected.+- **Treat all three as required**: Same drawback as above. Rejected.++### Consequences++**Positive:**+- Card renders on every day with the two core figures, regardless of off-peak data hygiene.+- Off-peak savings $0.00 is a defensible value when the split isn't known.++**Negative:**+- Users may misread "$0.00 off-peak savings" as "I had no off-peak imports" rather than "the data isn't available." Acceptable — the partial-coverage caption on History does not flag this case, but the day-detail view's off-peak split tile would be missing anyway in this state.++---++## Decision 17: Update-overlap check excludes the period under update++**Date**: 2026-05-23+**Status**: accepted++### Context++The naive overlap check would cause every update to fail because the period being updated overlaps itself.++### Decision++On update, the period being updated is excluded from the overlap check (AC 1.7). On create, the check considers every existing period.++### Rationale++Standard CRUD semantics; obvious in hindsight, but worth pinning down so an implementer doesn't write the naive query.++### Alternatives Considered++None — this is a correctness fix, not a design choice.++### Consequences++**Positive:**+- Updates work.++**Negative:**+- One extra "is this an update? exclude the id" branch in the validation path. Trivial.++---++## Decision 18: Zero is a valid kWh value; the card hides only on a missing aggregate row++**Date**: 2026-05-23+**Status**: accepted++### Context++The initial "priced day" definition required peak-import kWh and solar export kWh to be "available," which is ambiguous: does an overcast day with `0` solar export count as available? Does a day where the user fully self-consumed (so `0` peak imports) count? The brief reviewer (user) flagged that 0 is itself a legitimate measured value and asked whether the card hides on those days.++### Decision++`0` is a legitimate measured value on all three input kWh fields and contributes to the cost computation unchanged. The card hides only when the entire daily-energy aggregate row is missing for the day. An individual input kWh field absent from an otherwise-present row is treated as `0` for the corresponding cost line.++### Rationale++The data model stores aggregate rows with optional fields; an unset numeric field on an otherwise-populated row is most often "no activity" rather than "data unavailable." Hiding the card on a fully-self-consumed day (`0` peak imports) would mislead the user — that's a meaningful "$0.00 spent on grid imports today" outcome that deserves to be shown. Same for an overcast `0` solar export day. The only state in which the card couldn't sensibly render is when there is no daily aggregate at all, and that state already suppresses every other Day Detail card by virtue of having nothing to render.++### Alternatives Considered++- **Hide card whenever any input field is `0`**: Conservative but misleading — would erase legitimate cost figures on common days.+- **Hide card whenever any input field is unset (treating unset as different from `0`)**: Keeps fidelity to data hygiene at the cost of intermittent disappearance of the card during partial backfills. Rejected — failure mode is opaque to the user.++### Consequences++**Positive:**+- The card renders on every covered day that has a daily-energy row, including all-self-consumption and zero-solar days.+- Card visibility tracks "is there data to show at all" rather than the more fragile "is every field populated."++**Negative:**+- A partial backfill that leaves a row with one zero field will show a zero cost line that might not reflect reality; mitigated because the same partial state would corrupt the energy figures on the same card anyway, so the issue is not unique to costs.++---++## Decision 19: History costs land on a new "Period costs" card, not in the existing 8-tile overview++**Date**: 2026-05-23+**Status**: accepted++### Context++The History "Period overview" card already renders eight stat tiles. The four new cost figures (total peak imports cost, total solar income, net, total off-peak savings) could either extend that card to twelve tiles or live in a new card directly below.++### Decision++Render the four cost tiles on a new `HistoryPeriodCostsCard` placed directly below `HistoryStatsOverviewCard`. The new card is conditionally rendered (only when at least one priced day exists in the active range) and carries the partial-coverage caption inside the card body.++### Rationale++The existing 8-tile card is unconditional. Extending it would force "no pricing configured" to leave four em-dashed tiles permanently visible — at best noise, at worst confusing. A separate card disappears cleanly when there is nothing to show, and the partial-coverage caption ("N of M days priced") naturally belongs to the costs card rather than hanging off the side of a card that doesn't otherwise have one.++### Alternatives Considered++- **Extend the existing 8-tile overview to 12 tiles**: Tighter visual grouping. Rejected — couples cost rendering to overview rendering, and the empty-state behaviour is awkward.+- **Place the cost tiles inline with each per-day row**: Out of scope per Non-Goals; not reconsidered.++### Consequences++**Positive:**+- Clean conditional show/hide.+- Partial-coverage caption has an obvious home.++**Negative:**+- One more card to scroll past on the History screen. Acceptable given the screen already paginates by card.++---++## Decision 20: Storage encoding for rates — Float64 with 4-dp normalisation on write++**Date**: 2026-05-23+**Status**: accepted++### Context++The codebase stores every numeric value (kWh, percentages, SoC, watts) as `float64` / `Double`. No money / decimal type exists. Decision 10 fixed rate precision at exactly four decimal places. The encoding choice was between `float64` (codebase precedent), `int64` tenth-of-cent units, or introducing `shopspring/decimal`.++### Decision++Rates are stored and computed as `float64` (Go) / `Double` (Swift). The Lambda validator rejects rate payloads with more than four decimal places (`rate_precision` error code) and rounds accepted rates to exactly four decimals before persistence. Cost computation uses raw `float64` multiplication; display rounds to two decimals.++### Rationale++Float64 has 15+ significant decimal digits, enough to round-trip four-decimal rates without drift. Worst-case accumulated drift across 30 days × 3 multiplications × $1/kWh sits well below `$0.01`, which is the display granularity anyway. Introducing a decimal type would touch every cost site for no observable benefit and break the codebase's "every number is `float64`" convention. Integer microcents would impose conversion at every boundary for the same lack of benefit.++### Alternatives Considered++- **Integer tenth-of-cent units (`int64`)**: Avoids float arithmetic entirely. Rejected — adds conversion code at every API edge for zero observable improvement at our display precision.+- **`shopspring/decimal` (Go) / `Foundation.Decimal` (Swift)**: Exact arithmetic. Rejected — codebase precedent is `float64`; a new type for three fields is disproportionate.++### Consequences++**Positive:**+- Matches existing codebase convention.+- No new dependencies, no new conversion code.++**Negative:**+- Drift exists in principle; bounded sub-cent in practice. Future audit-ready accounting would need to change this.++---++## Decision 21: Sentinel-row pattern for AC 1.9 atomicity++**Date**: 2026-05-23+**Status**: accepted++### Context++Design-critic flagged that the initial atomicity design only used ConditionExpressions on the closing row and the new row inside the `TransactWriteItems` transaction. That guard catches the "two writers close the same row simultaneously" race but not the broader "two writers create a second open-ended row simultaneously" race that AC 1.9 prohibits. With the validator scan and the transaction running sequentially, two concurrent creators could both pass the validator and both succeed.++### Decision++The `flux-pricing` table holds a singleton sentinel row with `pricingId = "__open_ended"` and one functional attribute `openEndedId: string | null`. Every write that introduces, retires, or replaces the open-ended period maintains this row inside the same `TransactWriteItems` request, with a `ConditionExpression` on its previous value. Two concurrent writers cannot both observe and update the sentinel from the same previous value — the second one fails with `ConditionalCheckFailed`, mapped to HTTP 409 `concurrent_open_ended_write`. The editor refetches and retries on 409.++### Rationale++DynamoDB supports up to 100 items per `TransactWriteItems` and we're using at most 3 (sentinel + closing row + new row). Optimistic concurrency on the sentinel is the standard pattern for single-flight invariants and avoids the alternative of scanning every existing row inside the transaction's `ConditionCheck` items, which would scale linearly with pricing-row count.++### Alternatives Considered++- **Best-effort enforcement**: rely on the closing-row ConditionExpression alone and accept that AC 1.9 can be violated under rare concurrency. Document the violation as self-healing on next list-fetch. Rejected — easy to fix correctly with the sentinel; the design shouldn't ship a known correctness gap.+- **ConditionCheck against every existing pricing row**: each transaction asserts `attribute_exists(endDate)` on every other row. Scales O(n). Rejected — overkill for the invariant we need.+- **Scan inside the transaction**: not supported by `TransactWriteItems`. Rejected.++### Consequences++**Positive:**+- AC 1.9 enforced atomically under arbitrary concurrency.+- Single source of truth for "which row is open-ended."+- 3-item transactions stay well under DynamoDB's 100-item limit.++**Negative:**+- Every write that touches an open-ended period must read the sentinel first to capture `prevOpenEndedID`. One extra `GetItem` per such write.+- Sentinel must be filtered out of `ListPricing` results. Trivial guard in the reader.+- Initial provisioning has to create the sentinel row with `openEndedId = null`. CloudFormation custom resource or first-write-creates pattern.++---++## Decision 22: Pricing dates carried as `String` end-to-end on the client++**Date**: 2026-05-23+**Status**: accepted++### Context++Design-critic noted that `DayEnergy.date` is `String` ("YYYY-MM-DD"), not `Date`. The initial design's "closed-interval test" implied a `Date` comparison strategy that doesn't exist in the codebase.++### Decision++`PricingPeriod.startDate` and `endDate` are `String` ("YYYY-MM-DD") on the Swift side, matching `DayEnergy.date`. The priced-day predicate compares strings lexicographically — correct for ISO-formatted dates. No `Date` conversion is needed for the lookup. `createdAt` / `updatedAt` decode as `Date` (RFC3339) since they're not used for day arithmetic.++### Rationale++ISO `YYYY-MM-DD` strings sort identically under lexicographic and chronological comparison. Avoiding the `Date` round-trip eliminates a class of timezone bugs at the cost of nothing — the pricing-period lookup never needs `Date` semantics like calendar math or time-zone conversion.++### Alternatives Considered++- **Decode to `Date` in Australia/Melbourne**: Conceptually cleaner. Rejected — adds timezone-handling code for a comparison that works trivially on strings, and `DayEnergy.date` is already `String` so a round-trip would be needed at every comparison site.++### Consequences++**Positive:**+- No timezone bugs at the day-membership boundary.+- Symmetry with the existing `DayEnergy.date` type.++**Negative:**+- The editor's date picker has to format to `YYYY-MM-DD` on save. Trivial; `DateFormatter.iso8601(only: .date)` exists.++---++## Decision 23: Peak-imports fallback when off-peak split is nil++**Date**: 2026-05-23+**Status**: accepted++### Context++Design-critic flagged that `DayEnergy.peakGridImportKwh` is computed as `eInput - offpeakGridImportKwh`, which returns `nil` when `offpeakGridImportKwh == nil`. Decision 16 says off-peak absence must not hide the card; Decision 18 says zero is a valid kWh value. The behaviour on `nil` off-peak split was not pinned in the design.++### Decision++When `dayEnergy.offpeakGridImportKwh == nil`, the cost computation treats:+- Peak imports kWh = `dayEnergy.eInput ?? 0` (all grid imports are billed as peak).+- Off-peak savings kWh = `0` → off-peak savings displays "$0.00".++When `dayEnergy.offpeakGridImportKwh != nil`, the computation uses `dayEnergy.peakGridImportKwh ?? 0` and `dayEnergy.offpeakGridImportKwh ?? 0` directly.++### Rationale++Decision 9 frames peak as "everything outside the off-peak window." If the split is unknown, conservatively treating all imports as peak gives the user the larger of the two possible cost figures — the safer default for a billing-cost display. Off-peak savings degrades to zero gracefully, consistent with Decision 16's "off-peak unset → savings $0.00".++### Alternatives Considered++- **Hide the card when off-peak split is nil**: Contradicts Decision 16, which already settled this. Rejected.+- **Use `peakGridImportKwh ?? 0` regardless**: Would undercount peak imports on every stale-offpeak day (returning `0` instead of `eInput`). Rejected.+- **Split `eInput` 50/50 between peak and off-peak as a fallback**: Arbitrary. Rejected.++### Consequences++**Positive:**+- Cost card renders on every day with `eInput` present, even when the off-peak split is missing.+- Conservative default (over- rather than under-counts peak when uncertain).++**Negative:**+- A day with a missing off-peak split shows `$0.00` savings even if the user actually consumed off-peak energy. Mitigated because the missing-split state is rare on recent data and the Day Detail off-peak energy tile would already be missing in this state.++---++## Decision 24: Pricing list and replace-open-ended responses share a `pricing` envelope++**Date**: 2026-05-24+**Status**: accepted++### Context++During the post-merge design-critic review of the implementation, two production-breaking JSON envelope mismatches surfaced. The server's `GET /pricing` returns `{"pricing": [...]}` and `POST /pricing/replace-open-ended` returns `{"pricing": [closing, new]}`, but the original Swift client decoder expected `{"periods": [...]}` for the list endpoint and a bare `PricingPeriod` object from the replace endpoint. The design document specified the wire types for `PricingPayload` and the `pricingError` shape but was silent on the envelope key used by the multi-row endpoints.++### Decision++Standardise both multi-row pricing responses on the `{"pricing": [...]}` envelope. Single-row endpoints (`POST /pricing`, `PUT /pricing/{id}`) continue to return bare `PricingPeriod` objects. The Swift client decodes `replace-open-ended` into a new `ReplaceOpenEndedResult { closing, newPeriod }` value, and `PricingService.replaceOpenEnded` folds both rows into its local list instead of only the new row.++### Rationale++Both endpoints already returned the same shape on the server, so aligning the client to the server avoided a server change and any deployed-Lambda compatibility window. Keeping a structured `ReplaceOpenEndedResult` instead of returning `[PricingPeriod]` lets the editor surface "the open-ended period you just closed ended on X" without re-indexing the array, and pins the closing/new ordering at the type level.++### Alternatives Considered++- **Change the server to return `periods`**: Would require redeploying the Lambda before any client could be released and offered no advantage over aligning the client. Rejected.+- **Have `replaceOpenEndedPricing` return `[PricingPeriod]`**: Simpler but loses the closing/new distinction at the type level, forcing every caller to know which index is which. Rejected.++### Consequences++**Positive:**+- Server and client agree end-to-end on every pricing endpoint.+- The editor's optimistic fold updates both rows immediately, removing the dependency on the fire-and-forget refetch to surface the closing row's new `endDate`.++**Negative:**+- The protocol is mildly asymmetric — multi-row responses carry an envelope, single-row responses do not. This is consistent with how the SoC Alerts feature already shapes its responses.++---
diff --git a/specs/daily-costs/design.md b/specs/daily-costs/design.mdnew file mode 100644index 0000000..e902f29--- /dev/null+++ b/specs/daily-costs/design.md@@ -0,0 +1,448 @@+# Design: Daily Costs++## Overview++Add a single shared pricing-period configuration (CRUD on the Lambda; one new DynamoDB table) and a pure-Swift cost computation in FluxCore that consumes the pricing list together with the existing daily-energy figures to render a four-row costs card on Day Detail and a four-tile "Period costs" card on History. No `/day` or `/history` response changes.++## Architecture++```+ ┌───────────────────────┐+ │ flux-pricing (DDB) │+ │ PK: pricingId │+ └───────────▲───────────┘+ │ TransactWriteItems / Put / Update / Delete / Scan+ ┌───────────┴───────────┐+ │ internal/dynamo/ │+ │ pricing.go │+ └───────────▲───────────┘+ │ PricingReadAPI / PricingWriteAPI+ ┌───────────┴───────────┐+ │ internal/api/ │+ │ pricing_handler.go │+ └───────────▲───────────┘+ │ HTTP — 5 routes on the existing http.ServeMux+ │ GET /pricing+ │ POST /pricing+ │ PUT /pricing/{id}+ │ DELETE /pricing/{id}+ │ POST /pricing/replace-open-ended+ ┌───────────┴───────────┐+ │ URLSessionAPIClient │ ─► PricingService (ObservableObject)+ └───────────┬───────────┘ │+ │ │ pricing: [PricingPeriod]+ ▼ ▼+ ┌─────────────────────────────────────────────┐+ │ FluxCore/Pricing/ │+ │ PricingPeriod.swift │+ │ PricingPeriodDraft.swift │+ │ DayCosts.swift ← pure compute │+ │ PeriodCosts.swift ← pure compute │+ └─────────────────────────────────────────────┘+ │ │+ ▼ ▼+ Day Detail card History "Period costs" card+```++Integration points (with file:line anchors from precedent):++| Integration | File | Anchor |+|---|---|---|+| New table resource | `infrastructure/template.yaml` | mirror `NotesTable` (lines 447–467); insert after `SocFireStateTable` (~line 529) |+| Lambda IAM | `infrastructure/template.yaml` | new `Effect: Allow` block in `FluxLambdaPolicy` (~line 294) |+| Lambda env var | `infrastructure/template.yaml` | `TABLE_PRICING: !Ref PricingTable` in `ApiFunction.Environment.Variables` (~line 561) |+| Required env vars | `cmd/api/main.go:35–49` | add `"TABLE_PRICING"` to `requiredEnvVars` |+| Mux wiring | `internal/api/handler.go:71–83` | five `mux.HandleFunc(...)` calls inside `buildMux()` |+| Handler dependency injection | `cmd/api/main.go:51–65` | new `pricingStoreAdapter` bridging `PricingReadAPI` + `PricingWriteAPI` |+| Settings entry (iOS) | `Flux/Flux/Settings/SettingsView.swift:119–125` | new `Section("Pricing")` matching SoC Alerts shape |+| Settings entry (macOS) | `Flux/Flux/Settings/SettingsView.swift:224–235` | new `LiquidGlassSection`/`FormRow` row |+| Day Detail card placement | `Flux/Flux/DayDetail/DayDetailView.swift` | inserted directly below `DayInFiveBlocksPanel` |+| History card placement | `Flux/Flux/History/HistoryView.swift` | inserted directly below `HistoryStatsOverviewCard` |++The poller is untouched: pricing is read-only for the poller (and not even that — the poller has no business with pricing). Only the Lambda touches `flux-pricing`.++## Components and Interfaces++### Backend — `internal/dynamo/pricing.go`++```go+type PricingItem struct {+ PricingID string `dynamodbav:"pricingId" json:"id"`+ StartDate string `dynamodbav:"startDate" json:"startDate"` // YYYY-MM-DD, Melbourne+ EndDate *string `dynamodbav:"endDate,omitempty" json:"endDate,omitempty"`+ PeakRate float64 `dynamodbav:"peakRate" json:"peakRate"` // AUD/kWh, 4dp+ FeedInRate float64 `dynamodbav:"feedInRate" json:"feedInRate"`+ OffPeakSavingsRate float64 `dynamodbav:"offPeakSavingsRate" json:"offPeakSavingsRate"`+ CreatedAt string `dynamodbav:"createdAt" json:"createdAt"` // RFC3339+ UpdatedAt string `dynamodbav:"updatedAt" json:"updatedAt"`+}++// PricingSentinel pins which row is currently open-ended. Singleton with PK = "__open_ended".+// Every write that introduces, retires, or replaces the open-ended period maintains this row+// inside the same TransactWriteItems request, with a ConditionExpression on its previous value.+// This is what makes AC 1.9 ("at most one open-ended period") race-safe.+type PricingSentinel struct {+ PricingID string `dynamodbav:"pricingId"` // always "__open_ended"+ OpenEndedID *string `dynamodbav:"openEndedId,omitempty"`+ UpdatedAt string `dynamodbav:"updatedAt"`+}++type PricingReadAPI interface {+ ListPricing(ctx context.Context) ([]PricingItem, error) // excludes the sentinel row+ GetPricing(ctx context.Context, id string) (*PricingItem, error)+ GetSentinel(ctx context.Context) (*PricingSentinel, error) // for the validator pass+}++type PricingWriteAPI interface {+ PutPricing(ctx context.Context, item PricingItem, prevOpenEndedID *string) error+ UpdatePricing(ctx context.Context, item PricingItem, prevOpenEndedID *string) error+ DeletePricing(ctx context.Context, id string, prevOpenEndedID *string) error+ ReplaceOpenEnded(ctx context.Context, closingID string, closingEndDate string, newItem PricingItem) error+}+```++Behavioural contracts:++- `ListPricing` returns items sorted by `StartDate` ascending and **filters out the sentinel row** (`pricingId = "__open_ended"`). The API never exposes the sentinel.+- Every write that touches an open-ended period maintains the sentinel inside the same `TransactWriteItems` request. `prevOpenEndedID` is the validator's snapshot of the sentinel just before the write; the sentinel `ConditionExpression` is `(attribute_not_exists(pricingId) OR openEndedId = :prevOpenEndedID)` — the first clause lets the very first write create the sentinel, and the second clause catches concurrent writers on every subsequent write.++**`PutPricing` transaction shapes**:++| Case | Items in transaction | Sentinel target |+|---|---|---|+| New closed period | Plain `PutItem` (no transaction) | — |+| New open-ended period | (1) Sentinel Update; (2) Put new row | `null → newID` |++**`UpdatePricing` transaction shapes** (the design-critic's four sub-cases):++| Case | Items in transaction | Sentinel target |+|---|---|---|+| closed → closed (rate-only edit on a closed period) | Plain `UpdateItem` (no transaction) | — |+| closed → open (extending an existing closed period to be open-ended) | (1) Sentinel Update; (2) Row Update | `null → rowID` |+| open → closed (capping the open-ended period with an end date) | (1) Sentinel Update; (2) Row Update | `rowID → null` |+| open → open (rate-only edit on the open-ended row) | (1) Sentinel Update (no-op write, asserts `openEndedId = rowID`); (2) Row Update | `rowID → rowID` |++The open→open case still needs the sentinel ConditionCheck even though the value doesn't change — otherwise a concurrent closed→open could leave the sentinel pointing at a closed row.++**`DeletePricing` transaction shapes**:++| Case | Items in transaction | Sentinel target |+|---|---|---|+| Delete a closed period | Plain `DeleteItem` (no transaction) | — |+| Delete the open-ended period | (1) Sentinel Update; (2) Delete row | `rowID → null` |++**`ReplaceOpenEnded` transaction** (always three items): (1) Sentinel Update `closingID → newItem.PricingID` (or `→ null` if `newItem.EndDate != nil`); (2) Closing-row Update with `ConditionExpression: attribute_not_exists(endDate) AND pricingId = :closingId`; (3) Put new row with `ConditionExpression: attribute_not_exists(pricingId)`.++The atomicity guarantee from this design covers AC 1.9 in full (single open-ended period). It does NOT cover AC 1.7 (no overlap) for concurrent *closed* period creates — two concurrent writers that both pass the validator's overlap check on the same uncovered date range would both succeed. For the two-user Flux deployment this race is implausible and recoverable; the design accepts it as a documented limit. If AC 1.7 ever needs hard race-safety, the same sentinel pattern can be extended with a generation counter that every closed-period write must increment.++### Backend — `internal/api/pricing_handler.go`++One handler per route. Validation chain per AC 1.10:+```+inverted_dates → overlap → rate_precision → rate_out_of_range → second_open_ended+```+Validation runs server-side only. The client may pre-check for UX but never as the only check.++Overlap detection: load all pricing rows (small table, low volume), build `[]dateRange{start, end}` (open-ended modelled as `end = nil`), check the candidate against each — exclude the row's own id on update. O(n) check, n ≤ ~50 for a decade of use.++Error response shape: `{"error": "<code>", "message": "<one-line>"}` — HTTP 400 for all validation errors, 401 for auth, 404 for unknown id on delete/update, 500 for storage errors.++### Backend — atomic `replace-open-ended` endpoint++Request body:+```json+{ "closingPricingId": "uuid", "newPeriod": { "startDate": "2026-05-23", "endDate": null, "peakRate": ..., "feedInRate": ..., "offPeakSavingsRate": ... } }+```++The handler derives the closing row's new `endDate` as `newPeriod.startDate − 1 day` in `Australia/Melbourne` (no `closeAt` field on the wire — the offset is fixed by AC 3.6), reads the sentinel to capture `prevOpenEndedID`, runs the full validation chain against the resulting two-row state, then calls `ReplaceOpenEnded`. `TransactionCanceledException` is mapped back per the table below.++Decision: clients invoke this endpoint only from the editor's "close current open-ended period" remediation flow; the normal create path uses `POST /pricing`.++### `TransactionCanceledException` → HTTP mapping++`TransactWriteItems` returns a per-item `Reasons[]` array on cancellation. Mapping:++| Item index | Condition that failed | HTTP | Code |+|---|---|---|---|+| 0 (sentinel) | `openEndedId != prevOpenEndedID` — another writer raced this one | 409 | `concurrent_open_ended_write` |+| 1 (closing row, replace-open-ended only) | `attribute_not_exists(endDate)` failed — row was closed since validator scan | 409 | `concurrent_open_ended_write` |+| 1 or 2 (new row) | `attribute_not_exists(pricingId)` failed — UUID collision | 500 | `internal_error` (caller retries with a fresh UUID) |++A `Reasons` array that is empty or has all-`None` entries falls through to HTTP 500; the handler logs the raw exception. The editor's overlap-remediation flow treats 409 as "refetch the list and retry" — the same UX as any optimistic-concurrency miss.++### FluxCore — pricing models++```swift+public struct PricingPeriod: Identifiable, Codable, Sendable, Equatable, Hashable {+ public let id: String+ public let startDate: String // YYYY-MM-DD, Melbourne local calendar+ public let endDate: String?+ public let peakRate: Double+ public let feedInRate: Double+ public let offPeakSavingsRate: Double+ public let createdAt: Date // RFC3339, decoded as `Date`+ public let updatedAt: Date+}++public struct PricingPeriodDraft: Codable, Sendable, Equatable {+ public let startDate: String+ public let endDate: String?+ public let peakRate: Double+ public let feedInRate: Double+ public let offPeakSavingsRate: Double+}+```++Dates are stored as `YYYY-MM-DD` strings to match `DayEnergy.date` (which is `String`, not `Date`, per `APIModels.swift`). Day-membership tests use lexicographic string comparison, which is correct for ISO-formatted `YYYY-MM-DD` strings. No `Date` round-trip is needed for the priced-day predicate. `createdAt` / `updatedAt` decode as `Date` since they're not used for day arithmetic, only for ordering and debugging.++Numeric fields use Swift `Double`; precision loss at four decimals over 30 days is sub-cent (Decision 20).++### FluxCore — `DayCosts`, `PeriodCosts`++`DaySummary` (per `APIModels.swift:375`) is the type held by `DayDetailViewModel.summary`. It has no `date` field, so the cost lookup takes the date as a separate argument. `DayEnergy` (held by `HistoryViewModel.days`) carries `date` inline.++```swift+public struct DayCosts: Equatable, Sendable {+ public let peakImportsCost: Double+ public let solarFeedInIncome: Double+ public let net: Double+ public let offPeakSavings: Double+}++public extension DaySummary {+ /// Day Detail call site. Returns nil when no pricing period covers+ /// `date`. Zero-valued kWh fields produce zero cost lines and do NOT+ /// make the day unpriced (Decision 18).+ func costs(forDate date: String, in pricing: [PricingPeriod]) -> DayCosts?+}++public extension DayEnergy {+ /// History per-day call site. Convenience wrapper that forwards+ /// `self.date` to the `DaySummary` extension above; History accumulates+ /// over `[DayEnergy]` so this is what `PeriodCosts.compute` calls.+ func costs(in pricing: [PricingPeriod]) -> DayCosts?+}++public struct PeriodCosts: Equatable, Sendable {+ public let peakImportsCost: Double+ public let solarFeedInIncome: Double+ public let net: Double+ public let offPeakSavings: Double+ public let pricedDayCount: Int+ public let totalDayCount: Int+ public var hasPartialCoverage: Bool { pricedDayCount < totalDayCount }+}++public extension PeriodCosts {+ /// Builds totals from the daily summaries. Returns nil iff no day in+ /// `days` is a priced day, matching AC 5.4.+ static func compute(days: [DayEnergy], pricing: [PricingPeriod]) -> PeriodCosts?+}+```++Implementation notes (non-obvious):++1. **Period selection**: the extensions pick the single covering period using `startDate <= date <= (endDate ?? "9999-12-31")` on string compare. Overlap is impossible by AC 1.7 between pricing periods, so the first match is the only match. Returns `nil` when no period covers the day.++2. **kWh resolution from `DaySummary` (resolves the off-peak split ambiguity per Decisions 9, 16, 18)**:+ - When `summary.offpeakGridImportKwh != nil`:+ - peak imports kWh = `(summary.eInput ?? 0) − (summary.offpeakGridImportKwh ?? 0)`, clamped to ≥0.+ - off-peak kWh = `summary.offpeakGridImportKwh ?? 0`.+ - When `summary.offpeakGridImportKwh == nil`:+ - peak imports kWh = `summary.eInput ?? 0` (all grid imports billed as peak, since the split is unknown).+ - off-peak kWh = `0` → off-peak savings $0.00.+ Solar export kWh = `summary.eOutput ?? 0` in both cases.++3. **Net**: `peakImportsCost - solarFeedInIncome`. Off-peak savings is excluded from net (AC 4.5).++4. **`DaySummary` does not currently have a `peakGridImportKwh` convenience accessor**. The cost computation derives the peak-imports value inline from `eInput` and `offpeakGridImportKwh`. No new field on `DaySummary` is added — keeps the data model unchanged.++### FluxCore — `URLSessionAPIClient` extensions++Mirror the SoC Alerts shape (file:`URLSessionAPIClient.swift:86–130`):++```swift+public func fetchPricing() async throws -> [PricingPeriod]+public func createPricing(_ draft: PricingPeriodDraft) async throws -> PricingPeriod+public func updatePricing(id: String, _ draft: PricingPeriodDraft) async throws -> PricingPeriod+public func deletePricing(id: String) async throws+public func replaceOpenEndedPricing(closingId: String, with draft: PricingPeriodDraft) async throws -> PricingPeriod+```++`replaceOpenEndedPricing` takes only `closingId` plus the new draft; the server derives the closing row's end date as `draft.startDate − 1 day` (no `closeAt` on the wire — AC 3.6 fixes the offset at 1 day).++Error mapping: 400 responses get surfaced as `FluxAPIError.pricingValidation(.invertedDates / .overlap(openEndedId:) / .ratePrecision / .rateOutOfRange / .secondOpenEnded)`. 409 responses with code `concurrent_open_ended_write` become `FluxAPIError.pricingValidation(.concurrentWrite)`; the editor handles this by `await service.refresh()` and prompting the user to retry.++### FluxCore — `PricingService` (`@Observable @MainActor`)++`@Observable` class (Swift 6 macro), `@MainActor`-isolated (matches `SoCAlertsService` and the project's Swift rules for view-bound observables). iOS 26 / macOS 26 baseline. Holds the canonical `var pricing: [PricingPeriod]`. Refresh policy (AC 2.7):++- Settings opens Pricing pane → `refresh()`.+- Day Detail appears → `refresh()` (one HTTP call per day-detail-open).+- History range changes → `refresh()`.+- After any mutating call → the service folds the response into the local list **and** calls `refresh()` once more in the background; this gives the editor immediate UI feedback while still satisfying AC 2.7's "re-fetch the pricing list … immediately after any mutating call." The second fetch is fire-and-forget and resolves race conditions with concurrent writers on another device.++The service injects into `DayDetailViewModel`, `HistoryViewModel`, and the Settings flow via the existing app composition (mirror `SoCAlertsService` wiring at app startup in `FluxApp.swift` / `FluxiOSAppDelegate.swift`).++### iOS / macOS UI — Settings++Files (new, under `Flux/Flux/Settings/Pricing/`):+- `PricingPeriodsView.swift` — list, mirrors `SoCAlertsView.swift`+- `PricingEditor.swift` — sheet, mirrors `SoCAlertEditor.swift`; rate inputs use a `Decimal` formatter at 4 dp+- `PricingViewModel.swift` — wraps `PricingService`++The editor's "close-and-create" remediation is a one-tap button that surfaces only when create returns `overlap` with `overlapTarget = openEndedId`. The button calls `replaceOpenEndedPricing`.++### iOS / macOS UI — Day Detail card++New file: `Flux/Flux/DayDetail/CostsCard.swift`. Four rows in a fixed order (peak imports, solar income, net, off-peak savings). Two-column layout: label on left, value right-aligned. Currency: `$X.XX`. Negative net: leading `−` (existing Day Detail typographic treatment).++Wiring: `DayDetailViewModel` takes `pricing: [PricingPeriod]` from `PricingService` and computes `var costs: DayCosts?` on the fly from `dayEnergy`. The view renders `CostsCard(costs:)` only when `costs != nil`.++### iOS / macOS UI — History "Period costs" card++New file: `Flux/Flux/History/HistoryPeriodCostsCard.swift`. Four tiles in a 2×2 grid on iPhone, 1×4 on iPad/macOS — same `StatTile` flavour used by `HistoryStatsOverviewCard`. Partial-coverage caption sits beneath the four tiles as a single line in the card's `tertiaryText` treatment.++Cost totals are **not** folded into `HistoryViewModel.PeriodSummary`. The reason is signature: `DerivedState.init(days:now:)` does not currently take pricing, and threading a `pricing: [PricingPeriod]` parameter through every call site is more disruptive than the costs feature warrants. Instead:++- Costs are computed in a separate second pass via `PeriodCosts.compute(days:pricing:)` (defined in FluxCore, see above).+- `HistoryViewModel` exposes a computed `var periodCosts: PeriodCosts?` that runs `compute` over the current `days` + `pricingService.pricing`. Re-runs whenever the active range changes, on view appear, and on pricing mutation (via the `@Observable` registration of `pricingService.pricing`).+- `HistoryView` renders `HistoryPeriodCostsCard(costs:)` only when `periodCosts != nil`.++`HistoryViewModel.PeriodSummary` itself is unchanged.++### Pattern-extension audit++| Existing pattern | Touch site needed for daily-costs? |+|---|---|+| `Section("Alerts")` in `SettingsView.swift` (iOS + macOS) | Yes — add parallel `Section("Pricing")` immediately above the existing Alerts section so both configuration entries sit together |+| `HistoryViewModel.PeriodSummary` totals (`HistoryDerivedState.swift`) | **No** — costs are computed in a separate `PeriodCosts.compute` pass; `PeriodSummary` is unchanged |+| `DerivedState.init(days:now:)` signature | **No** — kept stable; pricing is consumed only by the separate cost pass |+| `SettingsViewModel` injection | Yes — add `pricingService` ref |+| `DayDetailViewModel` injection | Yes — add `pricingService` ref |+| `HistoryViewModel` injection | Yes — add `pricingService` ref, expose computed `periodCosts: PeriodCosts?` |+| `MacRefreshCoordinator` refresh tiers | No — pricing changes don't need refresh tier; just trigger `refresh()` on view appear |+| `URLSessionAPIClient.interpret(...)` | Yes — add 400-with-`pricing*` and 409-with-`concurrent_open_ended_write` error mapping |+| `FluxiOSAppDelegate` / app composition | Yes — instantiate `PricingService` at app startup, hand into the three view models (mirror `SoCAlertsService` wiring) |+| `cmd/api/main.go` `requiredEnvVars` | Yes — add `TABLE_PRICING` |+| Adapter structs in `cmd/api/main.go:62–64` (`socRuleStoreAdapter` style) | Yes — add `pricingStoreAdapter` |+| `internal/api/handler.go` `buildMux()` | Yes — add five routes |+| `internal/api/handler.go` shared dependencies (`PricingStore` field) | Yes |+| Lambda integration tests (`internal/api/*_test.go`) | Yes — new `pricing_test.go` + `pricing_atomicity_test.go` |+| Backfill CLI (`cmd/backfill-solar` shape) | No — pricing has no derived persistence |++## Data Models++Only one new model: `flux-pricing` table (above). No changes to existing tables. No changes to existing API response shapes. No FluxCore model changes outside the new `Pricing/` directory.++CloudFormation outline (insert after `SocFireStateTable`):++```yaml+PricingTable:+ Type: AWS::DynamoDB::Table+ DeletionPolicy: Retain+ Properties:+ TableName: flux-pricing+ BillingMode: PAY_PER_REQUEST+ AttributeDefinitions:+ - AttributeName: pricingId+ AttributeType: S+ KeySchema:+ - AttributeName: pricingId+ KeyType: HASH+ PointInTimeRecoverySpecification:+ PointInTimeRecoveryEnabled: true+```++### Sentinel-row initial provisioning++The sentinel row (`pricingId = "__open_ended"`) is created lazily on first write that needs it. The ConditionExpression on every sentinel-touching write is:++```+ConditionExpression: attribute_not_exists(pricingId) OR openEndedId = :prevOpenEndedID+```++The first transactional write that creates an open-ended period passes `:prevOpenEndedID = null` (anything works in the second clause; the first clause carries the write). The same expression catches every concurrent writer thereafter. No CloudFormation custom resource is needed.++If the sentinel row genuinely does not exist before the very first read, `GetSentinel` returns `nil` and the validator treats that as "no open-ended period exists" — equivalent to a sentinel row with `openEndedId = null`. The `attribute_not_exists` clause in the ConditionExpression keeps the first write race-safe between two cold-starting Lambdas: both try to create the row, the loser fails with `ConditionalCheckFailed` (mapped to 409 / refetch / retry).++## Error Handling++| Failure mode | HTTP | Error code | Surfaced as |+|---|---|---|---|+| Missing/wrong bearer token | 401 | — | `FluxAPIError.unauthorized` |+| Validation: end < start | 400 | `inverted_dates` | `FluxAPIError.pricingValidation(.invertedDates)` |+| Validation: overlap | 400 | `overlap` | `.pricingValidation(.overlap(openEndedId: ...))` so editor can offer remediation |+| Validation: >4 dp | 400 | `rate_precision` | `.pricingValidation(.ratePrecision)` |+| Validation: rate out of range | 400 | `rate_out_of_range` | `.pricingValidation(.rateOutOfRange)` |+| Validation: second open-ended | 400 | `second_open_ended` | `.pricingValidation(.secondOpenEnded)` |+| Delete / update unknown id | 404 | — | `.notFound` |+| Sentinel ConditionCheck fail or closing-row ConditionCheck fail | 409 | `concurrent_open_ended_write` | `.pricingValidation(.concurrentWrite)` — editor refetches the list and retries |+| Unexpected `TransactionCanceledException` (incl. UUID collision, empty `Reasons[]`) | 500 | `internal_error` | `.serverError` (existing) |+| Storage / unexpected | 500 | — | `.serverError` (existing) |++Non-obvious: `overlap` carries the offending row's id back to the editor when (and only when) the offender is the unique open-ended period. The editor then surfaces the one-tap close-and-create button.++## Testing Strategy++### Go (backend)++- `internal/dynamo/pricing_test.go`:+ - Round-trip `PutPricing` / `GetPricing` / `UpdatePricing` / `DeletePricing` for both closed and open-ended periods.+ - `ListPricing` ordering invariant (start-date ascending) and sentinel-row exclusion.+ - Sentinel maintenance: every transactional write leaves the sentinel pointing at the unique open-ended row (or `null` when none exists).+- `internal/dynamo/pricing_atomicity_test.go` — `TransactWriteItems` failure shapes:+ 1. **Sentinel race on replace-open-ended** — another writer flipped the sentinel between validator scan and transaction. ConditionCheck on `openEndedId = :prevOpenEndedID` fails → `Reasons[0] = ConditionalCheckFailed`. Assert: both rows untouched, sentinel untouched, handler returns HTTP 409 `concurrent_open_ended_write`.+ 2. **Closing-row race on replace-open-ended** — the closing row's `attribute_not_exists(endDate)` fails because another writer just closed it. `Reasons[1] = ConditionalCheckFailed`. Assert: HTTP 409.+ 3. **UUID collision** on new row — `attribute_not_exists(pricingId)` fails. `Reasons[2] = ConditionalCheckFailed`. Assert: HTTP 500 `internal_error`.+ 4. **`Reasons` array unpopulated** — SDK quirk path. Assert: HTTP 500 with the raw `TransactionCanceledException` message logged and no partial state visible in a subsequent `ListPricing`.+ 5. **PutPricing for a new open-ended period — sentinel race**: `openEndedId != null` at transaction time. Assert: HTTP 409, no new row created.+ 6. **UpdatePricing closed→open — sentinel race**: same as case 5. Assert: row update reverts, HTTP 409.+ 7. **UpdatePricing open→closed — sentinel race**: `openEndedId != rowID`. Assert: row update reverts, HTTP 409.+ 8. **UpdatePricing open→open (rate-only edit) — sentinel race**: `openEndedId != rowID` because a concurrent writer flipped it. Assert: row update reverts, HTTP 409.+ 9. **DeletePricing of open-ended row — sentinel race**: same as case 7. Assert: row not deleted, HTTP 409.+ 10. **First-write sentinel creation race**: two callers both observe `GetSentinel = nil` and both submit a transaction with `attribute_not_exists(pricingId)`. Assert: one succeeds, the other gets HTTP 409, no partial state.+- At least one integration test against DynamoDB Local exercising cases 1, 5, and 8 — unit tests with mocks cannot prove DynamoDB-side atomicity.+- `internal/api/pricing_handler_test.go`: per-endpoint happy + sad paths covering every error code (`inverted_dates`, `overlap`, `rate_precision`, `rate_out_of_range`, `second_open_ended`, plus the 409 race codes); 401 path; 404 on delete unknown id; `POST /pricing/replace-open-ended` happy path and full `Reasons[]` mapping coverage.+- Integration: `internal/integration/` if it exists — otherwise per-handler unit tests against the in-memory store like SoC Alerts.++### Swift (FluxCore)++- `DayCostsTests`:+ - Linearity: `costs(scaleRate, kWh) = scale × costs(rate, kWh)` for each line.+ - Zero kWh → zero cost on each line.+ - Net definition: `net == peakImportsCost − solarFeedInIncome` (off-peak savings excluded).+ - Day with no covering period returns `nil`.+ - Day with covering period but missing aggregate row → `nil` (model exposes this via `DayEnergy?` at the call site; covered by a separate "missing row" test).+- `PeriodCostsTests`:+ - `pricedDayCount` counts exactly the days with covering pricing.+ - `hasPartialCoverage` matches the AC 5.3 caption.+ - `nil` result when zero priced days in the range (AC 5.4).+- `URLSessionAPIClientPricingTests`: stub responses for each endpoint; 400 with each error code → typed `pricingValidation`.++### Property-based tests (Swift Testing's `@Test` + Gen + Sourcery-free hand-written generators, mirroring existing `SoCAlertRuleTests` style)++- **Overlap symmetry**: for any two periods `A`, `B`, `overlaps(A, B) == overlaps(B, A)`. Open-ended modelled as `endDate = nil`.+- **Cost linearity**: for any rate `r`, kWh `k`, `cost(r, k) = r × k`; properties: commutativity, `cost(0, k) == 0`, `cost(r, 0) == 0`.+- **`PeriodCosts.net` invariant**: `period.net == Σ day.net` over priced days.++Property tests run in `FluxCoreTests`; use `swift-testing`'s parameterised `@Test` with generated arrays of `(Double, Double)` for rates and kWh values, bounded by AC 1.8's `[0, 10.0]` for rates and `[0, 1000]` for kWh.++### iOS / macOS UI++- `PricingPeriodsView`: snapshot tests for `empty`, `one open-ended period`, `multiple periods sorted` states.+- `PricingEditor`: snapshot tests for `create` and `edit` modes; one snapshot per error banner.+- `CostsCard`: snapshot tests for positive net, negative net, and zero off-peak savings (no fractional dollars).+- `HistoryPeriodCostsCard`: full coverage (no caption) vs partial coverage (caption present) snapshots.++### Manual++- iCloud two-device exercise: edit pricing on iOS, verify macOS reflects the change after re-opening Day Detail / History (covers AC 2.7).+- Overlap remediation: trigger from editor with an open-ended period; confirm both rows land in one atomic transaction.+- Pricing-then-rate-change: set pricing, view Day Detail, edit the rate, confirm the same day re-renders with new figures.++## Operational notes++- **Lambda cold start**: pricing CRUD shares the existing Lambda binary, so cold-start latency for `/day` and `/history` is unchanged. The new routes pull pricing rows on first call after a cold start — one `Scan` (≤50 rows) plus one `GetItem` for the sentinel. Negligible add to the existing init cost.+- **DynamoDB cost**: pay-per-request billing means the pricing table consumes capacity only on actual reads/writes. At realistic usage (a handful of edits across the lifetime of the feature, plus one `Scan` + one `GetSentinel` per Settings-open and per Day Detail / History refresh on each of two devices), monthly spend is well under a cent. PITR on a sub-KB table adds a similar negligible cost.+- **Migration**: none. The new table is created empty by the CloudFormation deploy; the sentinel row is created lazily on the first pricing write.
diff --git a/specs/daily-costs/implementation.md b/specs/daily-costs/implementation.mdnew file mode 100644index 0000000..8d18e2d--- /dev/null+++ b/specs/daily-costs/implementation.md@@ -0,0 +1,428 @@+# Implementation: Daily Costs++Branch `T-1326/daily-costs`, branched from v1.3 (`9958320`). Spec sources:+[requirements](requirements.md), [design](design.md),+[decision log](decision_log.md), [tasks](tasks.md).++This explanation is structured at three expertise levels so a reader can+enter at the depth they need, and so the act of writing it surfaces gaps+in the implementation. The Completeness Assessment at the end pins which+acceptance criteria are fully satisfied, which are partial, and where the+implementation has silently diverged from the spec.++---++## Beginner Level++### What this does++Flux now shows what your day cost you. Open Day Detail and a new card+sits underneath the existing five-block panel with four lines:++- **Peak imports** — what you paid for grid electricity outside the+ off-peak window.+- **Solar feed-in** — what the grid paid you for the solar you exported.+- **Net** — peak imports minus feed-in. A positive number means you+ spent more than you earned; a leading "−" means the grid owed you.+- **Off-peak savings** — what your cheaper off-peak window grid+ electricity is worth (a separate "saved" amount, not subtracted from+ the net).++On the History screen, the same four numbers appear as totals for the+7/14/30-day range you're viewing. If only some of the days in the range+had pricing set up, a small caption underneath reads+"4 of 7 days priced" so the totals can't be misread as the whole range.++To make this work the app needs to know your electricity rates, so+Settings has a new "Pricing" entry. You add one or more *pricing+periods*: a start date, an optional end date, and three rates (peak,+feed-in, off-peak savings) per kWh in Australian dollars. You can change+or remove rates at any time, and the cost numbers update everywhere+they're shown.++### Why it matters++Until now, Flux showed only energy figures (kWh in / out / stored). To+reconcile against an electricity bill you had to do the multiplication+by hand. Now the cost is rendered directly in the app, and any rate+change rewrites every historical day's cost figure automatically.++### Key concepts++- **kWh**: the unit your battery, solar, and grid all measure. One kWh is+ the energy used by a 1000-watt appliance running for an hour.+- **Off-peak window**: a daily time-of-day band (configured to 11:00–14:00+ here) where the grid charges a cheaper rate for the electricity you+ import. Flux already records how many kWh you pulled from the grid+ inside that window each day.+- **Pricing period**: a date range plus the three rates that apply+ during it. A period with no end date is "open-ended" — it covers+ every day from its start onwards until you cap it.+- **DynamoDB**: the cloud database the backend uses. Pricing periods get+ a new table called `flux-pricing`.+- **Lambda**: the small serverless function that the iOS / macOS app+ talks to. It now has five new endpoints for managing pricing.++---++## Intermediate Level++### Changes overview++48 files changed, +7357 / −17 lines. The work splits cleanly into a+backend stream and a client stream, each landing as its own merge+commit on the feature branch.++**Go backend** (one new DynamoDB table, five new HTTP routes):++- `internal/dynamo/pricing.go` + `pricing_transactional.go` — CRUD+ storage with a sentinel-row pattern that uses `TransactWriteItems` to+ guarantee at most one open-ended period exists at any time.+- `internal/api/pricing.go` + `pricing_handler.go` — handlers for+ `GET/POST/PUT/DELETE /pricing` and `POST /pricing/replace-open-ended`,+ plus a validation chain that enforces the AC 1.10 ordering+ (`inverted_dates → overlap → rate_precision → rate_out_of_range →+ second_open_ended`).+- `infrastructure/template.yaml` — new `PricingTable` resource and IAM+ permissions; `cmd/api/main.go` adds `TABLE_PRICING` to its required+ env vars and a `pricingStoreAdapter` to bridge the two read/write+ interfaces.++**Swift FluxCore** (pure cost computation + service layer):++- `FluxCore/Pricing/PricingPeriod.swift` + `PricingPeriodDraft.swift` —+ the value types. Dates are stored as `String` (`YYYY-MM-DD`) so+ membership tests use lexicographic compare and avoid `Date` /+ timezone round-trips (Decision 22).+- `FluxCore/Pricing/DayCosts.swift` + `PeriodCosts.swift` — pure+ functions over `(DayEnergy, [PricingPeriod])`. Off-peak nil is+ treated as zero kWh (so the card still renders on days with a stale+ off-peak split) and peak imports fall back to all of `eInput` when+ the split is unknown (Decisions 16, 23).+- `FluxCore/Pricing/PricingService.swift` — `@Observable @MainActor`+ cache with optimistic in-place folds and a cancellable+ fire-and-forget refetch after every mutation.+- `URLSessionAPIClient.swift` — five `async throws` methods mirroring+ the new routes; 400 responses are decoded into typed+ `FluxAPIError.pricingValidation(.reason)` cases.++**Swift app** (Settings, Day Detail, History wiring):++- `Flux/Flux/Settings/Pricing/` — list view (mirrors SoC Alerts) and an+ editor sheet with inline validation, the one-tap+ close-and-create remediation, and a confirmation dialog for delete.+- `Flux/Flux/DayDetail/CostsCard.swift` + viewmodel wiring — renders+ the four-row card directly below the existing five-block panel.+- `Flux/Flux/History/HistoryPeriodCostsCard.swift` + viewmodel wiring —+ renders the four-tile card below `HistoryStatsOverviewCard`, with+ the partial-coverage caption beneath the grid.++### Implementation approach++**Cost math lives in the client.** Decision 14 chose client-side+computation over server-side enrichment. `/day` and `/history` response+shapes are unchanged. The client fetches the pricing list per AC 2.7+and multiplies it into existing kWh values; retroactive recomputation+is automatic because nothing is persisted in derived form.++**The sentinel row makes "one open-ended period" race-safe.** A+singleton row at `pricingId = "__open_ended"` carries one attribute,+`openEndedId`. Every write that introduces, retires, or replaces an+open-ended period puts an `Update` on the sentinel into the same+`TransactWriteItems` call with a `ConditionExpression` of+`attribute_not_exists(pricingId) OR openEndedId = :prev`. Two+concurrent writers cannot both observe and update the sentinel from the+same previous value — the loser gets `ConditionalCheckFailed` mapped to+HTTP 409 `concurrent_open_ended_write`.++**Atomic close-and-create.** The editor's overlap-remediation flow+calls `POST /pricing/replace-open-ended` instead of running two+sequential mutations. The server derives the closing row's new+`endDate` as `newPeriod.startDate − 1 day` (no `closeAt` on the wire —+AC 3.6 pins the offset), runs the full validation chain against the+projected two-row state, then commits the three-item transaction+(sentinel + close + insert). If anything fails, nothing persists.++**The PricingService folds responses optimistically and refetches.**+Every mutating call returns the affected row(s), which the service+inserts/replaces in `periods` immediately. It then kicks off a+fire-and-forget `refresh()` so AC 2.7's "re-fetch immediately after+any mutation" is satisfied while the editor sees instant feedback. As+part of the pre-push review the refetch now stores its `Task` handle+and cancels any prior in-flight refetch — without that, two back-to-+back saves could land out of order and the older response could+clobber the newer one.++### Trade-offs++- **`Float64` rates over `Decimal`** (Decision 20). The codebase+ already uses `float64` for everything, and at four decimal places+ with realistic kWh totals the drift is well below `$0.01`. Adopting+ `shopspring/decimal` (Go) or `Foundation.Decimal` (Swift) for three+ fields would touch every cost site for no observable benefit.+- **String date storage on the client** (Decision 22). `YYYY-MM-DD`+ strings sort identically under lexicographic and chronological+ compare, and `DayEnergy.date` is already `String`. Decoding to `Date`+ would add timezone-handling code for a comparison that needs none.+- **Sentinel pattern over per-row condition checks** (Decision 21). The+ alternative — asserting `attribute_exists(endDate)` on every other+ pricing row inside each transaction — scales O(n) with row count.+ The sentinel is O(1) and keeps the transaction at most three items.+- **Costs on a separate History card** (Decision 19). Folding the four+ cost tiles into the existing 8-tile overview was rejected because+ unconditional rendering would leave four em-dashed tiles permanently+ visible when no pricing is configured. A separate card disappears+ cleanly and gives the partial-coverage caption an obvious home.+- **`{"pricing": [...]}` envelope on multi-row responses** (Decision+ 24). Added post-implementation when a client/server envelope+ mismatch surfaced. Single-row endpoints return bare objects; only+ multi-row responses use the envelope. Mildly asymmetric, but+ matches the SoC Alerts precedent.++---++## Expert Level++### Technical deep dive++**Validation chain ordering matters.** AC 1.10 fixes the chain order+because the server returns the *first* error encountered, and+different orders surface different problems first when a payload+violates multiple rules. The implementation in+`runPricingValidationChain` walks them in `inverted_dates → overlap →+rate_precision → rate_out_of_range → second_open_ended` order. One+quirk: `second_open_ended` is dead code on the create path because+`validateOverlap` always fires first when two open-ended candidates+collide (both carry `endDate = "9999-12-31"` in the half-open interval+math). The handler will surface the situation as `overlap` instead.+The Swift editor doesn't notice — it routes both codes to a banner —+but a future client that wanted distinct UX for the two cases would+need either a reorder or a documented note.++**`9999-12-31` is an implementation sentinel inside `validateOverlap`,+not part of the wire contract.** A client that posted+`endDate = "9999-12-31"` on a closed period would be treated as+closed by the open-ended check (because `EndDate != nil`) but its+collision diagnostics would lose the "open-ended row id" hint. The+date is implausible enough that this is theoretical, but it's the+kind of implicit coupling a reviewer should know about.++**Delete uses `GetPricing` for the 404 short-circuit, not a conditional+`DeleteItem`.** Decision 11 advertises 404 on unknown id, but two+concurrent deletes can both pass the `GetPricing` check and both+return 204. The behavioural surface differs from the documented one+under racing deletes. Acceptable in practice (the UI refreshes the+list anyway, per Decision 11's negative consequence note), but a+`ConditionExpression: attribute_exists(pricingId)` on the closed-period+`DeleteItem` would tighten the contract.++**`pluckReplacedPair` was reading from an eventually-consistent+`Scan`.** The original `handleReplaceOpenEnded` re-listed the table+after the transaction to echo back both affected rows. DynamoDB+`Scan` is eventually consistent, so a stale read could return a+zero-value `PricingItem` for the just-written row, and the Swift+client would have folded an empty period into its local list. The+pre-push review rewrote this to synthesise the response from the+in-memory `closing` row (with its derived `endDate` and `updatedAt`)+plus the freshly-constructed `newItem`. Same wire shape, no race.++**The sentinel ConditionExpression covers two failure modes in one+expression.** `attribute_not_exists(pricingId) OR openEndedId = :prev`:+the first clause lets the very first write create the sentinel row+(no `Put` needed for provisioning); the second clause catches every+concurrent writer thereafter. The "first cold-start two-Lambda race"+case is documented in the design (case 10 in `pricing_atomicity_test.go`)+— both racers attempt the create, the loser fails with+`ConditionalCheckFailed`, and there's no partial state to clean up+because the sentinel is its own transaction item.++**Off-peak savings is a savings amount, not a counterfactual.**+Decision 5 settled the formula at `offPeakKwh × offPeakSavingsRate`+rather than `offPeakKwh × (peakRate − offPeakRate)`. The third rate is+named "off-peak savings per kWh" specifically because the user+encodes the savings figure directly. A future feature wanting to+report "what would this day have cost on the peak rate alone" would+have to add a separate computation; the current rate field can't be+reused for that.++**The History `periodCosts` and Day Detail `costs` accessors are+computed on every body evaluation.** SwiftUI `@Observable` re-runs+body when any tracked property changes, and these computed vars take+dependencies on `pricing` (a small array) and the day(s) being shown.+At realistic N (≤50 pricing rows, 30 history days) the work is+trivial — but the same view models cache other O(n) derived state+(`DayDetailViewModel.offpeakStats`) with an explicit "do this in+`loadDay`" comment. Future regression risk if these N's grow.++**`PricingService` was leaking detached tasks before the review.**+The original `scheduleRefetch()` fired `Task { try? await refresh() }`+with no stored handle. Two back-to-back mutations could land+out-of-order (older response overwrites newer) and the Task wasn't+cancellable on teardown. The fix stores `refetchTask: Task<Void, Never>?`+on the service, cancels the previous handle on every schedule, and+checks `Task.isCancelled` after the network round-trip in+`refresh()`. `DayDetailViewModel.comparisonTask` already used this+pattern; `PricingService` now matches.++### Architecture impact++- The `flux-pricing` table is read-only for the poller and CRUD-only+ for the Lambda. The poller's hot path is untouched. Cold-start cost+ for the Lambda gains one `dynamo.NewDynamoPricingStore` constructor+ (a struct alloc) and reuses the existing `ddbClient`.+- No change to `/status`, `/day`, or `/history` response shapes. A+ client that doesn't fetch `/pricing` sees no behavioural change —+ this is also why the feature flag is implicit ("did the user+ configure pricing?") rather than a server-side toggle.+- `PricingService` is registered at the composition root and threaded+ into `DayDetailViewModel`, `HistoryViewModel`, and the Settings+ flow. A `PricingService.shared` singleton fallback exists for+ preview / fast-path construction; the pre-push review noted this as+ a testability hazard worth addressing in a follow-up.+- The `pricing` JSON envelope is asymmetric (multi-row responses+ wrap; single-row responses don't). Decision 24 documents the choice+ and the SoC Alerts precedent that justified it. Any new pricing+ endpoint that returns ≥2 rows should use the envelope.++### Potential issues++- **Concurrent deletes return 204 instead of the documented 404** (see+ above). Practical impact is nil for a two-user system but the+ contract drifts from Decision 11.+- **Dead `second_open_ended` code path on create** (see above).+- **No DynamoDB Local integration tests.** The design's testing+ strategy explicitly called out that mocked atomicity tests cannot+ prove DynamoDB-side `ConditionExpression` behaviour for cases 1, 5,+ and 8. The mock-based `pricing_atomicity_test.go` proves the+ `Reasons[]` mapping but not the engine-level invariant.+- **`PricingService.shared` singleton fallback in three view-model+ default constructors.** Tests that don't inject a service will see+ state leaked from prior runs or previews. Removing the fallback and+ forcing explicit injection at the composition root is the right+ long-term fix.+- **Costs recompute on every SwiftUI body re-evaluation.** Auto-+ refresh (every 10s on Dashboard, which scrolls through DayDetail+ via the date carousel) and chart cursor scrubbing trigger frequent+ body evals. Negligible CPU today; worth caching in `loadDay()` /+ `loadHistory()` if the cost model ever grows beyond six+ multiplications per day.+- **`PricingPeriodsView` and `SoCAlertsView` share ~200 lines of+ near-identical structure** (Form wrappers, error banner, empty+ state, swipe-to-delete, sheet plumbing). The duplication is+ intentional in the spec ("mirror SoC Alerts") but invites drift on+ the next typography or layout change.++---++## Completeness Assessment++### Fully implemented (verified against the diff)++- **Requirement 1** (storage and validation): all 10 ACs have+ corresponding code. Validation chain ordering is correct.+- **Requirement 2** (HTTP API): all 7 ACs implemented; bearer-token+ middleware reused; list sorted; transactional replace-open-ended+ in place; client refresh policy correct.+- **Requirement 3** (Settings UI): all 9 ACs implemented across iOS+ and macOS; remediation flow wires through `replaceOpenEnded`;+ empty-state names the unlocked feature; layouts mirror SoC Alerts.+- **Requirement 4** (Day Detail card): all 8 ACs implemented; four+ rows in the required order; AUD formatting with leading `−` on+ negative net; off-peak nil → `$0.00`; card hidden when no covering+ period.+- **Requirement 5** (History totals): all 5 ACs implemented; the+ partial-coverage caption now renders beneath the four tiles (this+ was a defect fixed in the pre-push review).+- **Requirement 6** (retroactive recomputation): both ACs implemented;+ no per-day cost snapshot anywhere.++### Decisions reflected in code++All 24 decisions are observably honoured. Notable confirmations:++- **D10 / D20**: rates rounded to 4 dp on write+ (`roundTo4DP`), monetary totals displayed at 2 dp+ (`CostsCard.formatAUD`). `Float64` arithmetic throughout.+- **D16 / D23**: off-peak nil → 0 fallback in+ `DayCosts.costs(forDate:in:)`; peak imports fall back to all of+ `eInput`.+- **D21**: sentinel row at `pricingId = "__open_ended"`; every+ open-ended-touching write maintains the sentinel inside the same+ transaction with the documented `ConditionExpression`.+- **D22**: dates carried as `String` end-to-end on the Swift client;+ `PricingPeriod.covers(date:)` uses lexicographic compare.+- **D24**: `{"pricing": [...]}` envelope on `GET /pricing` and `POST+ /pricing/replace-open-ended`; `ReplaceOpenEndedResult` decoded from+ the envelope, now matched by id rather than array position after+ the pre-push review.++### Partial / extended (documented divergences worth noting)++- **AC 2.4 vs Decision 11**: concurrent deletes can both return 204.+ Practical impact is nil; the documented behaviour ("second device+ sees 404") is not what the code actually does. Either tighten with+ a `ConditionExpression: attribute_exists(pricingId)` on+ `DeleteItem`, or update Decision 11.+- **AC 3.5**: validation errors surface as a banner block beneath the+ rate inputs rather than inline against the offending field. The+ spec's "where possible" language tolerates this; a tighter+ implementation would attach date-shaped errors to the Dates section+ and rate-shaped errors to the Rates section.+- **AC 3.7**: the editor adds an extra `confirmationDialog` for+ delete that the SoC Alerts pattern doesn't have. Defensible (a+ whole pricing period is more destructive than a single alert rule)+ but technically diverges from "matching the existing SoC Alerts+ delete confirmation pattern."+- **CostsCard `±0.005 → $0.00` rendering** is a no-negative-zero UX+ tweak not captured in the decision log. Worth either documenting or+ reverting for strict spec conformance.+- **Editor's open-ended toggle** initialises `endDate = startDate` on+ toggle-off, producing a one-day period unless the user changes the+ end date. Defaulting to `startDate + 30 days` (or hinting the+ one-day duration) would be a friendlier default.++### Testing gaps++- **No DynamoDB Local integration tests** for atomicity cases 1, 5,+ and 8. The design called these out explicitly because mock-based+ tests cannot prove DynamoDB-side `ConditionExpression` behaviour.+- **No test asserts the `[closing, new]` ordering of the+ `replace-open-ended` response under odd start-date orderings.** The+ client now matches by id (post-review fix), so the regression risk+ is much smaller, but a handler-level test would lock the contract.++### Documentation++- **CHANGELOG** has a thorough Unreleased entry. The caption phrasing+ was corrected during the pre-push review to match the code+ ("N of M days priced", not "N of M days have pricing").+- **Decision log** is comprehensive (24 entries), including the+ post-implementation Decision 24 for the JSON envelope fix.+- **Tasks** marks all 31 tasks `[x]`.++---++## Validation findings++- **AC 5.3 caption placement** was wrong on first cut — the caption+ was passed as the `kpi:` argument of `HistoryCardChrome` and+ rendered as a headline in the top-right of the card header. The+ pre-push review moved it beneath the four-tile grid per the spec+ and the design's "single line in the card's tertiaryText+ treatment" note.+- **`pluckReplacedPair` could return zero-value rows** from an+ eventually-consistent post-write `Scan`. Replaced with synthesis+ from the in-memory transaction inputs; same wire shape, no race+ window.+- **`scheduleRefetch` leaked Tasks and allowed out-of-order+ responses.** Now stores a single task handle, cancels the prior+ one on every schedule, and `refresh()` checks+ `Task.isCancelled` before mutating `periods`.+- **`URLSessionAPIClient.replaceOpenEndedPricing` assumed positional+ response order.** Now matches by `closingId`, hardening the client+ against a future server-side reorder.+- **Implementation extensions not in the decision log** (CostsCard+ `±0.005 → $0.00`, editor confirmation dialog, open-ended toggle's+ endDate default) are worth either capturing as decisions or+ trimming for strict spec conformance. Calling them out so the+ author can pick.
diff --git a/specs/daily-costs/requirements.md b/specs/daily-costs/requirements.mdnew file mode 100644index 0000000..7482ffa--- /dev/null+++ b/specs/daily-costs/requirements.md@@ -0,0 +1,106 @@+# Requirements: Daily Costs++## Introduction++Show monetary costs and income alongside the existing energy figures on Day Detail and the History Period Overview, driven by user-configured per-kWh rates stored centrally so a single tenant-wide pricing setup serves both users. Three rates per pricing period — peak grid usage, solar feed-in, and off-peak savings — are entered in AUD and apply to every day the period covers. When no pricing period covers a day, that day shows no cost figures at all.++## Non-Goals++- Per-user or per-device pricing (single shared tenant-wide configuration only).+- Localised currency or multi-currency support (AUD only).+- A snapshot/frozen cost history — cost figures are always recomputed from the current pricing config.+- Cost figures rendered per-day on individual History rows (only aggregated period totals are shown on History).+- Cost figures on Dashboard, home-screen widgets, Control Center widget, or What's New.+- Cost lines on Day Detail other than the four specified (peak imports, solar income, net, off-peak savings).+- Pricing tier transitions within a single day (a day uses exactly one pricing period; mid-day rate changes are not modelled).+- Tax / GST line items, fixed daily supply charges, or other non-per-kWh fees.+- A third "shoulder" or "peak window" tariff bucket — every grid import outside the SSM off-peak window is treated as peak-rate.+- Time-of-use bands beyond the existing off-peak window already configured via SSM (`/flux/offpeak/*`).+- Export / share of cost figures (CSV, PDF, sheet, share extension).++## Requirements++### 1. Pricing Period Storage++**User Story:** As a Flux user, I want pricing periods stored centrally, so that both users see the same configured rates without setting them up separately.++**Acceptance Criteria:**++1. <a name="1.1"></a>The system SHALL persist pricing periods centrally such that any authenticated client reads the same list.+2. <a name="1.2"></a>Each pricing period SHALL store a start date (YYYY-MM-DD, required), an optional end date (YYYY-MM-DD), a peak grid rate (AUD per kWh), a solar feed-in rate (AUD per kWh), and an off-peak savings rate (AUD per kWh).+3. <a name="1.3"></a>Pricing period start and end dates SHALL be interpreted in the same timezone as the off-peak SSM window (`/flux/offpeak/*`); the system SHALL persist a documented assumption of `Australia/Melbourne`.+4. <a name="1.4"></a>Each rate field SHALL be stored to exactly four decimal places of precision; a submitted rate with more than four decimal places SHALL be rejected with the `rate_precision` error code.+5. <a name="1.5"></a>A pricing period SHALL cover every calendar day from its start date to its end date inclusive (treating an absent end date as open-ended through the indefinite future).+6. <a name="1.6"></a>The system SHALL reject a pricing period whose end date is before its start date with the `inverted_dates` error code.+7. <a name="1.7"></a>The system SHALL reject a pricing period whose calendar-day set intersects any existing period's calendar-day set with the `overlap` error code; on update of an existing period, the period being updated SHALL be excluded from the overlap check.+8. <a name="1.8"></a>The system SHALL reject a pricing period with a rate less than 0 or greater than 10.0 AUD per kWh on any of the three rate fields with the `rate_out_of_range` error code.+9. <a name="1.9"></a>The system SHALL allow at most one pricing period to have an absent (open-ended) end date at any given time; an attempt to create or update a period that would produce a second open-ended period SHALL be rejected with the `second_open_ended` error code.+10. <a name="1.10"></a>WHEN a payload violates more than one validation rule above, the system SHALL return the first error encountered in the order ACs 1.6 → 1.7 → 1.4 → 1.8 → 1.9.++### 2. Pricing Management API++**User Story:** As the iOS / macOS app, I want endpoints to list, create, update, and delete pricing periods, so that the Settings UI can manage them.++**Acceptance Criteria:**++1. <a name="2.1"></a>The Lambda API SHALL expose endpoints to list all pricing periods, create a new period, update an existing period, and delete an existing period.+2. <a name="2.2"></a>Pricing endpoints SHALL require the same bearer-token authentication used by every other Flux Lambda endpoint and SHALL return HTTP 401 when the token is missing or wrong.+3. <a name="2.3"></a>Create and update SHALL return HTTP 400 with a single machine-parseable error code drawn from the set {`overlap`, `inverted_dates`, `rate_out_of_range`, `rate_precision`, `second_open_ended`} when a validation rule from Requirement 1 fails.+4. <a name="2.4"></a>Delete SHALL return HTTP 404 when the period id is unknown.+5. <a name="2.5"></a>The list endpoint SHALL return periods sorted by start date ascending.+6. <a name="2.6"></a>The API SHALL expose a single transactional endpoint that, in one request, closes the existing open-ended period at a caller-supplied date AND creates a new pricing period; if either operation fails for any reason, neither SHALL be persisted.+7. <a name="2.7"></a>The client SHALL re-fetch the pricing list every time the user opens Settings → Pricing, every time Day Detail loads, every time the History range changes (7/14/30), and immediately after any mutating call; no longer-lived cache SHALL be used for cost computation.++### 3. Settings UI — Pricing Periods++**User Story:** As a Flux user, I want to add, edit, and delete pricing periods from Settings on both iOS and macOS, so that I can keep the rates current as my electricity contract changes.++**Acceptance Criteria:**++1. <a name="3.1"></a>Settings SHALL include a "Pricing" entry that opens a list of all configured pricing periods, sorted by start date ascending.+2. <a name="3.2"></a>Each row SHALL show the period's date range (with the open-ended period rendered as "from <start>"), the three configured rates formatted to four decimal places (e.g. "$0.2873 / kWh"), and a tap target to open the editor.+3. <a name="3.3"></a>The list SHALL include an explicit add affordance matching the existing SoC Alerts add-rule pattern.+4. <a name="3.4"></a>The editor SHALL provide inputs for start date, end date (optional), peak grid rate, solar feed-in rate, and off-peak savings rate, with rate inputs accepting up to four decimal places.+5. <a name="3.5"></a>The editor SHALL surface backend validation errors (`overlap`, `inverted_dates`, `rate_out_of_range`, `rate_precision`, `second_open_ended`) inline against the offending field where possible, and otherwise as a banner on the editor sheet.+6. <a name="3.6"></a>WHEN a create attempt fails with `overlap` because the new period starts inside an existing open-ended period, the editor SHALL offer a one-tap remediation that invokes the transactional close-and-create endpoint of AC 2.6 with the existing open-ended period's new end date set to the new period's start date minus one day; the remediation SHALL leave no partial state behind on failure.+7. <a name="3.7"></a>The editor SHALL provide a destructive delete action for existing periods, behind a confirmation matching the existing SoC Alerts delete confirmation pattern.+8. <a name="3.8"></a>When no pricing periods are configured, the list SHALL show an empty-state message naming the feature it unlocks (e.g. costs appearing on Day Detail and History).+9. <a name="3.9"></a>Settings SHALL match the iOS and macOS pricing list and editor layouts to the existing SoC Alerts Settings pattern (Form/List on iOS, native Settings scene on macOS).++### 4. Day Detail Costs Card++**User Story:** As a Flux user, I want to see the monetary breakdown for a single day alongside its energy figures, so that I can quantify what the day cost or earned.++**Acceptance Criteria:**++A day is a *priced day* when (a) it is covered by exactly one pricing period, AND (b) the day's daily-energy aggregate row exists in the data store. A value of `0` on any of the three input kWh fields (peak imports, solar export, off-peak imports) is a legitimate measured figure — an overcast day really does have `0` solar export and a full-self-consumption day really does have `0` peak imports — and SHALL be carried through the cost computation unchanged. An individual input kWh figure that is absent from an otherwise-present aggregate row SHALL be treated as `0` for the corresponding cost line. The off-peak window grid-import kWh figure SHALL additionally be treated as `0` when the off-peak SSM window is unset or zero-width. The card hides only when the entire daily-energy aggregate row is missing — the same condition under which Day Detail has no energy figures to show anyway.++1. <a name="4.1"></a>WHEN a viewed day is a priced day, Day Detail SHALL render a costs card containing four rows: peak imports cost, solar feed-in income, net, and off-peak savings.+2. <a name="4.2"></a>Peak imports cost SHALL equal the day's peak-import kWh × the covering period's peak grid rate.+3. <a name="4.3"></a>Solar feed-in income SHALL equal the day's solar export kWh × the covering period's solar feed-in rate.+4. <a name="4.4"></a>Off-peak savings SHALL equal the day's off-peak window grid-import kWh × the covering period's off-peak savings rate, with the off-peak kWh resolved per the priced-day definition above; the resulting savings value SHALL be displayed as "$0.00" when the resolved off-peak kWh is `0`.+5. <a name="4.5"></a>The net row value SHALL equal peak imports cost − solar feed-in income; off-peak savings does NOT contribute to the net.+6. <a name="4.6"></a>WHEN the viewed day is not a priced day, Day Detail SHALL NOT render the costs card.+7. <a name="4.7"></a>Each monetary value on the costs card SHALL be formatted as AUD with two decimal places (e.g. "$3.42"), using a leading "−" for negative results.+8. <a name="4.8"></a>The costs card SHALL appear directly below the existing five-block panel on Day Detail.++### 5. History Period Costs Totals++**User Story:** As a Flux user, I want aggregated cost totals for the active History range, so that I can compare cost across 7/14/30-day periods.++**Acceptance Criteria:**++1. <a name="5.1"></a>WHEN at least one day in the active History range is a priced day (per the definition in Requirement 4), the History Period Overview card SHALL include four total tiles: total peak imports cost, total solar feed-in income, net total, and total off-peak savings.+2. <a name="5.2"></a>Each total SHALL be the sum of the per-day figures computed per Requirement 4 across only priced days within the active range.+3. <a name="5.3"></a>WHEN fewer than 100% of days in the active range are priced days, the cost tile block SHALL display a caption beneath the four tiles in the form "N of M days priced" so the net figure is read with explicit coverage context.+4. <a name="5.4"></a>WHEN no day in the active History range is a priced day, History SHALL NOT render any of the cost tiles or the partial-coverage caption.+5. <a name="5.5"></a>Cost totals SHALL recompute on the active view's next render after the active range (7/14/30) changes or after pricing is mutated on the same device; propagation across devices follows the cache rules of AC 2.7.++### 6. Retroactive Recomputation++**User Story:** As a Flux user, I want pricing changes to affect historical days immediately, so that correcting a rate fixes every day it should fix.++**Acceptance Criteria:**++1. <a name="6.1"></a>Cost figures SHALL be derived on read from the stored kWh values and the current pricing configuration; no per-day cost figure SHALL be persisted as a frozen snapshot.+2. <a name="6.2"></a>WHEN a pricing period is created, updated, or deleted, every subsequent Day Detail or History view of a day in the affected range SHALL reflect the new pricing on its next load, without an app restart or cache invalidation step beyond the client refresh already triggered by the mutation (AC 2.7).
diff --git a/specs/daily-costs/tasks.md b/specs/daily-costs/tasks.mdnew file mode 100644index 0000000..2df3692--- /dev/null+++ b/specs/daily-costs/tasks.md@@ -0,0 +1,172 @@+---+references:+ - specs/daily-costs/requirements.md+ - specs/daily-costs/design.md+ - specs/daily-costs/decision_log.md+---+# Tasks: Daily Costs++## Backend++- [x] 1. Add flux-pricing table, IAM policy, and TABLE_PRICING env var in infrastructure/template.yaml and cmd/api/main.go requiredEnvVars <!-- id:osag2li -->+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1)++- [x] 2. Write internal/dynamo/pricing_test.go covering CRUD round-trip, ListPricing ordering and sentinel-row exclusion <!-- id:osag2lj -->+ - Blocked-by: osag2li (Add flux-pricing table, IAM policy, and TABLE_PRICING env var in infrastructure/template.yaml and cmd/api/main.go requiredEnvVars)+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.5](requirements.md#1.5), [2.5](requirements.md#2.5)++- [x] 3. Implement internal/dynamo/pricing.go with PricingItem, PricingSentinel, PricingReadAPI, PricingWriteAPI and basic non-transactional CRUD <!-- id:osag2lk -->+ - Blocked-by: osag2lj (Write internal/dynamo/pricing_test.go covering CRUD round-trip, ListPricing ordering and sentinel-row exclusion)+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.5](requirements.md#1.5), [2.5](requirements.md#2.5)++- [x] 4. Write internal/dynamo/pricing_atomicity_test.go enumerating the 10 TransactWriteItems failure shapes from the design <!-- id:osag2ll -->+ - Blocked-by: osag2lk (Implement internal/dynamo/pricing.go with PricingItem, PricingSentinel, PricingReadAPI, PricingWriteAPI and basic non-transactional CRUD)+ - Stream: 1+ - Requirements: [1.9](requirements.md#1.9), [2.3](requirements.md#2.3)++- [x] 5. Implement sentinel-aware transactional writes (Put/Update/Delete sub-cases and ReplaceOpenEnded) in internal/dynamo/pricing.go <!-- id:osag2lm -->+ - Blocked-by: osag2ll (Write internal/dynamo/pricing_atomicity_test.go enumerating the 10 TransactWriteItems failure shapes from the design)+ - Stream: 1+ - Requirements: [1.9](requirements.md#1.9)++- [x] 6. Write internal/api/pricing_test.go covering happy paths and every error code per AC 2.3, 2.4, 2.6, and the close-and-create endpoint <!-- id:osag2ln -->+ - Blocked-by: osag2lm (Implement sentinel-aware transactional writes (Put/Update/Delete sub-cases and ReplaceOpenEnded) in internal/dynamo/pricing.go)+ - Stream: 1+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6)++- [x] 7. Implement internal/api/pricing_handler.go with validation chain (AC 1.10), TransactionCanceledException to HTTP mapping, and replace-open-ended handler <!-- id:osag2lo -->+ - Blocked-by: osag2ln (Write internal/api/pricing_test.go covering happy paths and every error code per AC 2.3, 2.4, 2.6, and the close-and-create endpoint)+ - Stream: 1+ - Requirements: [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), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9), [1.10](requirements.md#1.10), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6)++- [x] 8. Wire pricing routes into internal/api/handler.go buildMux and add pricingStoreAdapter in cmd/api/main.go <!-- id:osag2lp -->+ - Blocked-by: osag2lo (Implement internal/api/pricing_handler.go with validation chain (AC 1.10), TransactionCanceledException to HTTP mapping, and replace-open-ended handler)+ - Stream: 1+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2)++## FluxCore — Models & Client++- [x] 9. Write FluxCoreTests/PricingPeriodTests.swift covering Codable round-trip, YYYY-MM-DD string compare ordering, and equality <!-- id:osag2lq -->+ - Stream: 2+ - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3)++- [x] 10. Implement FluxCore/Pricing/PricingPeriod.swift and PricingPeriodDraft.swift <!-- id:osag2lr -->+ - Blocked-by: osag2lq (Write FluxCoreTests/PricingPeriodTests.swift covering Codable round-trip, YYYY-MM-DD string compare ordering, and equality)+ - Stream: 2+ - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3)++- [x] 11. Write FluxCoreTests/URLSessionAPIClientPricingTests.swift covering all five endpoints plus 400/401/404/409 error mapping <!-- id:osag2ls -->+ - Blocked-by: osag2lr (Implement FluxCore/Pricing/PricingPeriod.swift and PricingPeriodDraft.swift)+ - Stream: 2+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)++- [x] 12. Implement URLSessionAPIClient pricing extensions and FluxAPIError.pricingValidation cases (including concurrentWrite) <!-- id:osag2lt -->+ - Blocked-by: osag2ls (Write FluxCoreTests/URLSessionAPIClientPricingTests.swift covering all five endpoints plus 400/401/404/409 error mapping)+ - Stream: 2+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)++- [x] 13. Write FluxCoreTests/PricingServiceTests.swift covering refresh, mutation fold and fire-and-forget refetch <!-- id:osag2lu -->+ - Blocked-by: osag2lt (Implement URLSessionAPIClient pricing extensions and FluxAPIError.pricingValidation cases (including concurrentWrite))+ - Stream: 2+ - Requirements: [2.7](requirements.md#2.7)++- [x] 14. Implement FluxCore/Pricing/PricingService.swift as @Observable @MainActor <!-- id:osag2lv -->+ - Blocked-by: osag2lu (Write FluxCoreTests/PricingServiceTests.swift covering refresh, mutation fold and fire-and-forget refetch)+ - Stream: 2+ - Requirements: [2.7](requirements.md#2.7)++## FluxCore — Cost Computation++- [x] 15. Write FluxCoreTests/DayCostsTests.swift covering DaySummary.costs(forDate:in:) and DayEnergy.costs(in:), including the off-peak nil fallback to eInput <!-- id:osag2lw -->+ - Blocked-by: osag2lr (Implement FluxCore/Pricing/PricingPeriod.swift and PricingPeriodDraft.swift)+ - Stream: 2+ - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [4.6](requirements.md#4.6)++- [x] 16. Implement FluxCore/Pricing/DayCosts.swift with DaySummary and DayEnergy extensions <!-- id:osag2lx -->+ - Blocked-by: osag2lw (Write FluxCoreTests/DayCostsTests.swift covering DaySummary.costs(forDate:in:) and DayEnergy.costs(in:), including the off-peak nil fallback to eInput)+ - Stream: 2+ - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [4.6](requirements.md#4.6), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2)++- [x] 17. Write FluxCoreTests/PeriodCostsTests.swift with property-based tests for overlap symmetry, cost linearity, and net invariant, plus partial-coverage and empty-range cases <!-- id:osag2ly -->+ - Blocked-by: osag2lx (Implement FluxCore/Pricing/DayCosts.swift with DaySummary and DayEnergy extensions)+ - 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), [5.5](requirements.md#5.5)++- [x] 18. Implement FluxCore/Pricing/PeriodCosts.swift with compute(days:pricing:) <!-- id:osag2lz -->+ - Blocked-by: osag2ly (Write FluxCoreTests/PeriodCostsTests.swift with property-based tests for overlap symmetry, cost linearity, and net invariant, plus partial-coverage and empty-range cases)+ - 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), [5.5](requirements.md#5.5)++## Settings UI++- [x] 19. Write PricingViewModel tests covering list/add/edit/delete, validation error surfacing, and the one-tap overlap remediation flow <!-- id:osag2m0 -->+ - Blocked-by: osag2lv (Implement FluxCore/Pricing/PricingService.swift as @Observable @MainActor)+ - Stream: 2+ - Requirements: [3.1](requirements.md#3.1), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7)++- [x] 20. Implement Flux/Flux/Settings/Pricing/PricingViewModel.swift wrapping PricingService <!-- id:osag2m1 -->+ - Blocked-by: osag2m0 (Write PricingViewModel tests covering list/add/edit/delete, validation error surfacing, and the one-tap overlap remediation flow)+ - Stream: 2+ - Requirements: [3.1](requirements.md#3.1), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7)++- [x] 21. Write snapshot tests for PricingPeriodsView covering empty, single open-ended, multiple sorted, and partially-loaded states <!-- id:osag2m2 -->+ - Blocked-by: osag2m1 (Implement Flux/Flux/Settings/Pricing/PricingViewModel.swift wrapping PricingService)+ - Stream: 2+ - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.8](requirements.md#3.8)++- [x] 22. Implement Flux/Flux/Settings/Pricing/PricingPeriodsView.swift with list, add affordance, swipe-to-delete, and empty state <!-- id:osag2m3 -->+ - Blocked-by: osag2m2 (Write snapshot tests for PricingPeriodsView covering empty, single open-ended, multiple sorted, and partially-loaded states)+ - Stream: 2+ - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9)++- [x] 23. Write snapshot tests for PricingEditor covering create, edit, per-error-code banners, and the remediation surface <!-- id:osag2m4 -->+ - Blocked-by: osag2m1 (Implement Flux/Flux/Settings/Pricing/PricingViewModel.swift wrapping PricingService)+ - Stream: 2+ - Requirements: [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6)++- [x] 24. Implement Flux/Flux/Settings/Pricing/PricingEditor.swift (sheet with 4dp rate inputs, destructive delete with confirmation, one-tap remediation button) <!-- id:osag2m5 -->+ - Blocked-by: osag2m4 (Write snapshot tests for PricingEditor covering create, edit, per-error-code banners, and the remediation surface)+ - Stream: 2+ - Requirements: [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7)++- [x] 25. Wire Settings entry on iOS and macOS in SettingsView.swift and instantiate PricingService at app startup (FluxApp.swift / FluxiOSAppDelegate.swift) <!-- id:osag2m6 -->+ - Blocked-by: osag2m3 (Implement Flux/Flux/Settings/Pricing/PricingPeriodsView.swift with list, add affordance, swipe-to-delete, and empty state), osag2m5 (Implement Flux/Flux/Settings/Pricing/PricingEditor.swift (sheet with 4dp rate inputs, destructive delete with confirmation, one-tap remediation button))+ - Stream: 2+ - Requirements: [3.1](requirements.md#3.1), [3.9](requirements.md#3.9)++## Day Detail Card++- [x] 26. Write snapshot tests for CostsCard covering positive net, negative net, and zero off-peak savings <!-- id:osag2m7 -->+ - Blocked-by: osag2lx (Implement FluxCore/Pricing/DayCosts.swift with DaySummary and DayEnergy extensions)+ - Stream: 2+ - Requirements: [4.1](requirements.md#4.1), [4.7](requirements.md#4.7)++- [x] 27. Implement Flux/Flux/DayDetail/CostsCard.swift (four rows, AUD formatting, leading minus on negative net) <!-- id:osag2m8 -->+ - Blocked-by: osag2m7 (Write snapshot tests for CostsCard covering positive net, negative net, and zero off-peak savings)+ - Stream: 2+ - Requirements: [4.1](requirements.md#4.1), [4.7](requirements.md#4.7)++- [x] 28. Wire costs computation into DayDetailViewModel and render CostsCard below DayInFiveBlocksPanel in DayDetailView.swift <!-- id:osag2m9 -->+ - Blocked-by: osag2lv (Implement FluxCore/Pricing/PricingService.swift as @Observable @MainActor), osag2m8 (Implement Flux/Flux/DayDetail/CostsCard.swift (four rows, AUD formatting, leading minus on negative net))+ - Stream: 2+ - Requirements: [4.1](requirements.md#4.1), [4.6](requirements.md#4.6), [4.8](requirements.md#4.8), [6.2](requirements.md#6.2)++## History Period Costs Card++- [x] 29. Write snapshot tests for HistoryPeriodCostsCard covering full coverage and partial coverage with the N-of-M caption <!-- id:osag2ma -->+ - Blocked-by: osag2lz (Implement FluxCore/Pricing/PeriodCosts.swift with compute(days:pricing:))+ - Stream: 2+ - Requirements: [5.1](requirements.md#5.1), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4)++- [x] 30. Implement Flux/Flux/History/HistoryPeriodCostsCard.swift (four tiles, partial-coverage caption) <!-- id:osag2mb -->+ - Blocked-by: osag2ma (Write snapshot tests for HistoryPeriodCostsCard covering full coverage and partial coverage with the N-of-M caption)+ - Stream: 2+ - Requirements: [5.1](requirements.md#5.1), [5.3](requirements.md#5.3)++- [x] 31. Wire periodCosts computation into HistoryViewModel and render HistoryPeriodCostsCard below HistoryStatsOverviewCard in HistoryView.swift <!-- id:osag2mc -->+ - Blocked-by: osag2lv (Implement FluxCore/Pricing/PricingService.swift as @Observable @MainActor), osag2mb (Implement Flux/Flux/History/HistoryPeriodCostsCard.swift (four tiles, partial-coverage caption))+ - Stream: 2+ - Requirements: [5.1](requirements.md#5.1), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [6.2](requirements.md#6.2)
Branch base is v1.3 (9958320). PR #53 (T-1150 iPad adaptive layout) landed on main after the base. git diff origin/main..HEAD --name-status shows the iPad files as D because they exist on main but not on this branch — a rebase or merge will preserve them. Merging this branch without rebasing first would silently revert the iPad work.
The new TABLE_PRICING env var is now required by cmd/api/main.go. A deploy that doesn't surface the env var on the Lambda would fail at init() with a clear error — but worth confirming by hitting /pricing once after deploy, before any client tries to use the editor.
The new PricingTable resource adds an IAM block to FluxLambdaPolicy. CloudFormation can manage this — but if the previous stack was created with a narrower policy, re-running the deploy will widen it. Confirm with aws lambda invoke or a quick GET /pricing after the stack update.
Edit pricing on iOS, watch macOS reflect the change after re-opening Day Detail or History. The fire-and-forget refetch now cancels properly, but the cross-device freshness still depends on the receiving device hitting an AC 2.7 trigger (Settings open, Day Detail load, History range change). Worth a five-minute manual exercise before declaring multi-device parity.
The full make ios-test suite was last run on the round-1 working tree (before commit). The round-2 changes only touched Go and one Swift file (URLSessionAPIClient) which the FluxCore swift test covers. The xcodebuild suite is unlikely to regress given the surface, but a final make ios-test before pushing closes the loop.