diff --git a/CLAUDE.md b/CLAUDE.md index f342a24..f7597d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -301,6 +301,11 @@ The rules below put these into practice. 6 or 8 times a power of ten), with grid lines and figures at half the top and the top where there is room. Now's strip carries the price now on as a dashed line. A price below zero hangs under the zero line. + Which price is a choice on the phone (Home › Price shown, Fredrik, + 1 Nov 2026): with or without the grid fee and tax, with or without VAT + (`PriceShown`, from `spotMinor`, `totalMinor` and the box's + `vatPermille`). Every chart that shows it says which, and a box that + does not send its rate gets the price the phone has, under its own name. - **Gold on a chart is the sun's.** The sun's curve and the battery giving power are both warm, and a tester read one as the other, so the battery runs in cyan both ways wherever a trace is drawn, as the grid runs in diff --git a/appleApp/FTW/UI/HomeTab.swift b/appleApp/FTW/UI/HomeTab.swift index 598aa49..1461299 100644 --- a/appleApp/FTW/UI/HomeTab.swift +++ b/appleApp/FTW/UI/HomeTab.swift @@ -13,6 +13,8 @@ struct HomeTabView: View { /// Back to the tab's root, after this phone is removed. let popToRoot: () -> Void @AppStorage(Appearance.key) private var appearance = Appearance.standard + @AppStorage(PriceShown.feesKey) private var fees = true + @AppStorage(PriceShown.vatKey) private var vat = true var body: some View { List { @@ -25,6 +27,7 @@ struct HomeTabView: View { } homes phone + price look about } @@ -92,6 +95,34 @@ struct HomeTabView: View { } } + /// Which price the app shows, on this phone: the total the household + /// pays, or the spot price without the grid fee and tax, each with or + /// without VAT. The footer names what is shown, so a price is never + /// read under the wrong name. + private var price: some View { + // Before the box's prices are read, the label names the choice; + // once they are, it names what they allow. + let held = home.prices.prices + let unsaid = held != nil && held?.vatPermille == nil + let kind = PriceShown(fees: fees, vat: vat).kind(vatPermille: unsaid ? nil : held?.vatPermille ?? 0) + return Section { + Toggle(L("With grid fee and tax"), isOn: $fees) + .accessibilityIdentifier("settings.price.fees") + Toggle(L("With VAT"), isOn: $vat) + .accessibilityIdentifier("settings.price.vat") + } header: { + Text(L("Price shown")) + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text(PriceShown.label(kind)).font(Theme.mono()).tracking(0.5) + if unsaid, kind != PriceShown(fees: fees, vat: vat).kind(vatPermille: 250) { + Text(L("Your box does not say its VAT rate yet, so FTW shows the price it has and says which.")) + } + Text(L("Now, the plan and the price on this phone. The widgets and the watch show the total with VAT.")) + } + } + } + /// Dark, light or the iPhone's own setting, for this app on this phone. private var look: some View { Section { diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 5bb304b..bd20144 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -423,16 +423,20 @@ private struct PlanSection: View { let zoom: Namespace.ID /// The pass a tap chose on the strip. @State private var chosenID: String? + @AppStorage(PriceShown.feesKey) private var fees = true + @AppStorage(PriceShown.vatKey) private var vat = true var body: some View { // The plan marks the minute, not the second: it redraws once a // minute however often the clock ticks. let minute = site.minuteMs - let window = prices.prices + // The price this phone chose to show, in the strip and its header. + let choice = PriceShown(fees: fees, vat: vat) + let window = prices.prices.map(choice.apply) let current = window.flatMap { w in w.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } } let shown = models.planShare.shown(nowMs: minute) let hasCar = !loadpoints.points.isEmpty - let strip = PlanStrip.build(shown.entry, moment: shown.moment, hasCar: hasCar) + let strip = PlanStrip.build(shown.entry, moment: shown.moment, hasCar: hasCar, prices: window?.slots) PlanStripView( strip: strip, sentence: shown.moment.nextText, @@ -442,7 +446,8 @@ private struct PlanSection: View { unit: PriceUnits.unit(window?.currency).label.uppercased(), fade: StripFade.make(models.planShare.before, strip: strip, nowMs: minute, hasCar: hasCar), zoom: zoom, - chosenID: $chosenID + chosenID: $chosenID, + priceLabel: window.map { PriceShown.label(choice.kind(vatPermille: $0.vatPermille)) } ) .equatable() // Capped where it is placed, so the strip's scaled sizes read the diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index eb82376..bf6999d 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -51,6 +51,8 @@ struct PlanStripView: View, Equatable { /// The least the price row takes: Now gives it what room it has; the /// plan, which scrolls, asks for more. var leastPrice: CGFloat = PlanStripLayout.leastPrice + /// Which price the bars are, "SPOT EXCL. VAT", after the kicker. + var priceLabel: String? /// Where the strip's drawing sits, for the passes' tap targets. @State private var canvas: CGRect = .zero @GestureState private var dragging = false @@ -62,6 +64,7 @@ struct PlanStripView: View, Equatable { a.strip == b.strip && a.sentence == b.sentence && a.shorter == b.shorter && a.placeholder == b.placeholder && a.priceNow == b.priceNow && a.unit == b.unit && a.fade == b.fade && a.zoom == b.zoom && a.chosenID == b.chosenID && a.opensPlan == b.opensPlan && a.levelHeight == b.levelHeight && a.leastPrice == b.leastPrice + && a.priceLabel == b.priceLabel } /// Mono stops growing at accessibility1. @@ -157,11 +160,12 @@ struct PlanStripView: View, Equatable { /// the strip as a link of its own. private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(strip.kicker) + Text(priceLabel.map { strip.kicker + " · " + $0 } ?? strip.kicker) .font(mono) .tracking(0.5) .foregroundStyle(Theme.ink2) .lineLimit(1) + .minimumScaleFactor(0.75) .contentTransition(.opacity) if opensPlan { Image(systemName: "chevron.right") diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 82e2242..52c39e0 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -122,6 +122,8 @@ private struct PlanBody: View { @State private var strips = StripMemo() /// The pass a tap chose on the strip, lit on the dial too. @State private var chosenID: String? + @AppStorage(PriceShown.feesKey) private var fees = true + @AppStorage(PriceShown.vatKey) private var vat = true @Namespace private var zoom static let topID = "plan.top" @@ -136,7 +138,10 @@ private struct PlanBody: View { let moved = models.planShare.moved let entry = models.planShare.entry let quarters = Int(((moment.toMs - minute) / PlanStrip.quarterMs).rounded(.up)) + 1 - let strip = strips.strip(entry, moment: moment, hasCar: !loadpoints.points.isEmpty, quarters: style == .lanes ? quarters : PlanStrip.count) + // The price this phone chose to show, in the strip and its header. + let choice = PriceShown(fees: fees, vat: vat) + let window = prices.prices.map(choice.apply) + let strip = strips.strip(entry, moment: moment, hasCar: !loadpoints.points.isEmpty, quarters: style == .lanes ? quarters : PlanStrip.count, prices: window?.slots) let chosen = strip.passes.first { $0.id == chosenID } ?? PlanPass.opening(strip.passes) Group { switch style { @@ -168,13 +173,14 @@ private struct PlanBody: View { sentence: moment.nextText, shorter: moment.nextShorter, placeholder: moment.placeholder, - priceNow: priceNow(minute), + priceNow: priceNow(window, minute), unit: PriceUnits.unit(prices.prices?.currency).label.uppercased(), zoom: zoom, chosenID: $chosenID, opensPlan: false, levelHeight: style == .lanes ? 50 : 0, - leastPrice: 56 + leastPrice: 56, + priceLabel: window.map { PriceShown.label(choice.kind(vatPermille: $0.vatPermille)) } ) .equatable() .fitCap(.xLarge) @@ -212,8 +218,8 @@ private struct PlanBody: View { } /// The price now in whole units, for the strip's header. - private func priceNow(_ minute: Double) -> String? { - guard let window = prices.prices else { return nil } + private func priceNow(_ window: Prices?, _ minute: Double) -> String? { + guard let window else { return nil } let current = window.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } return current.map { PriceStrip.whole($0.totalMinor, window.currency) } } @@ -260,13 +266,13 @@ private final class DialMemo { /// The strip worked out once per plan, minute and length, like the dial. @MainActor private final class StripMemo { - private var key: (entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int)? + private var key: (entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int, prices: [PriceSlot]?)? private var kept: PlanStrip? - func strip(_ entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int) -> PlanStrip { - if let key, key.entry == entry, key.moment == moment, key.hasCar == hasCar, key.quarters == quarters, let kept { return kept } - let s = PlanStrip.build(entry, moment: moment, hasCar: hasCar, quarters: quarters) - key = (entry, moment, hasCar, quarters) + func strip(_ entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int, prices: [PriceSlot]?) -> PlanStrip { + if let key, key.entry == entry, key.moment == moment, key.hasCar == hasCar, key.quarters == quarters, key.prices == prices, let kept { return kept } + let s = PlanStrip.build(entry, moment: moment, hasCar: hasCar, quarters: quarters, prices: prices) + key = (entry, moment, hasCar, quarters, prices) kept = s return s } diff --git a/appleApp/FTW/UI/PriceView.swift b/appleApp/FTW/UI/PriceView.swift index 8173f07..203c73f 100644 --- a/appleApp/FTW/UI/PriceView.swift +++ b/appleApp/FTW/UI/PriceView.swift @@ -104,6 +104,8 @@ struct PriceView: View { /// Reads the minute, the prices and the plan, and hands them on as values, /// so the page below redraws once a minute at most. private struct PriceBody: View { + @AppStorage(PriceShown.feesKey) private var fees = true + @AppStorage(PriceShown.vatKey) private var vat = true let site: SiteModel let prices: PriceModel let plan: PlanShare @@ -121,7 +123,11 @@ private struct PriceBody: View { // the first and last bars are whole. let from = (window?.fromMs ?? site.nowMs) - 3_600_000 let to = (window?.toMs ?? site.nowMs + 24 * 3_600_000) + 3_600_000 - PricePage(slots: prices.slots(fromMs: from, toMs: to), zone: held.zone, currency: held.currency, + // The price this phone chose to show, and what it holds. + let choice = PriceShown(fees: fees, vat: vat) + let kind = choice.kind(vatPermille: held.vatPermille) + PricePage(slots: choice.apply(prices.slots(fromMs: from, toMs: to), vatPermille: held.vatPermille), + shown: kind == .totalWithVAT ? nil : PriceShown.words(kind), zone: held.zone, currency: held.currency, window: window, minuteMs: site.minuteMs, plan: plan.entry, problem: prices.pastProblem, scrub: scrub, chartMax: chartMax, onPan: onPan, onPanEnded: onPanEnded) .equatable() @@ -136,6 +142,9 @@ private struct PriceBody: View { private struct PricePage: View, Equatable { let slots: [PriceSlot] + /// What the price holds when this phone chose other than the total + /// with VAT; nil reads it off the box's numbers. + let shown: String? let zone: String let currency: String let window: TimelineWindow? @@ -149,7 +158,7 @@ private struct PricePage: View, Equatable { let onPanEnded: () -> Void nonisolated static func == (a: Self, b: Self) -> Bool { - a.slots == b.slots && a.zone == b.zone && a.currency == b.currency && a.window == b.window + a.slots == b.slots && a.shown == b.shown && a.zone == b.zone && a.currency == b.currency && a.window == b.window && a.minuteMs == b.minuteMs && a.plan == b.plan && a.problem == b.problem && a.chartMax == b.chartMax && a.scrub === b.scrub } @@ -158,7 +167,7 @@ private struct PricePage: View, Equatable { let clock = ZoneClock.of(.current) let chart = PriceChart.build(slots, currency: currency, nowMs: minuteMs, fromMs: window?.fromMs, spanMs: window?.spanMs ?? 24 * 3_600_000, plan: plan, clock: clock) - let included = PriceGlance.text(PriceGlance.included(slots)) + let included = shown ?? PriceGlance.text(PriceGlance.included(slots)) // The window as the chart draws it, so a finger takes it from // exactly there even before anything has moved it. let drawn = window ?? TimelineWindow(fromMs: chart.grid.fromMs, toMs: chart.grid.toMs, nowMs: minuteMs) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index de88f0e..978e8c2 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -391,7 +391,8 @@ public final class SimulatedBox { slots.append(.map([("startMs", .number(t)), ("durationMs", .number(slotMs)), ("spotMinor", .number((total * 0.6).rounded())), ("totalMinor", .number(total))])) t += slotMs } - return .map([("zone", .text("SE3")), ("currency", .text("SEK")), ("slots", .array(slots)), ("stale", .bool(end < to))]) + // The VAT rate the totals carry, as a box from srcfl/ftw#1949 says it. + return .map([("zone", .text("SE3")), ("currency", .text("SEK")), ("slots", .array(slots)), ("stale", .bool(end < to)), ("vatPermille", .number(250))]) } // MARK: Commands diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 83c3683..2dc25f2 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -414,6 +414,13 @@ public enum NodeWords { "About FTW": "Om FTW", "Homes, settings and about.": "Hem, inställningar och om appen.", + // Which price the app shows + "Price shown": "Priset som visas", + "With grid fee and tax": "Med nätavgift och skatt", + "With VAT": "Med moms", + "Your box does not say its VAT rate yet, so FTW shows the price it has and says which.": "Boxen anger inte sin moms än, så FTW visar det pris den har och säger vilket.", + "Now, the plan and the price on this phone. The widgets and the watch show the total with VAT.": "Nu, planen och priset i den här telefonen. Widgetarna och klockan visar totalpriset med moms.", + // How the app looks "Appearance": "Utseende", "Dark": "Mörkt", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 911557e..d83efc4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1582,7 +1582,7 @@ extension SiteModel { spotMinor: Double(spot), totalMinor: Double(total))) } guard !slots.isEmpty else { return } - let prices = Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false) + let prices = Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false, vatPermille: p.vatPermille.map { Int($0) }) cloudPrices = Stamped(PriceEntry(fromMs: Double(p.t0Ms), prices: prices, timeZone: session.box?.tz), asOfMs: view.sentMs(p.header, arrivalMs: Int64(at)).map(Double.init) ?? at, origin: .cloud) sharePrices(prices, fromMs: Double(p.t0Ms), via: .cloud) diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index d2a9748..a45bcc2 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -92,14 +92,16 @@ public struct PlanStrip: Equatable, Sendable { /// from: the next twelve hours, or for the plan's lanes `quarters` /// (the whole plan), with the hours every six once it runs past a day's /// half. - public static func build(_ entry: PlanEntry?, moment: PlanGlance.Moment, hasCar: Bool, quarters wanted: Int = Self.count) -> PlanStrip { + public static func build(_ entry: PlanEntry?, moment: PlanGlance.Moment, hasCar: Bool, quarters wanted: Int = Self.count, prices shown: [PriceSlot]? = nil) -> PlanStrip { let clock = ZoneClock.of(moment.timeZone) let nowMs = moment.atMs let first = clock.alignDown(nowMs, stepMs: quarterMs) let count = max(Self.count, wanted) let grid = SlotGrid(firstMs: first, stepMs: quarterMs, count: count, fromMs: first, toMs: first + Double(count) * quarterMs) - let prices = (entry?.prices ?? []).filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } + // The prices this phone chose to show, where the price record has + // them, else the ones under the plan. + let prices = (shown ?? entry?.prices ?? []).filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } let slots = (entry?.battery ?? []).sorted { $0.startMs < $1.startMs } let charges = entry?.charges ?? [] let currency = entry?.currency ?? "SEK" diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift index cc910fa..6baba72 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift @@ -298,6 +298,11 @@ enum PriceWord: String, CaseIterable { case dayAverage = "Average %@ · lowest %@ at %@" case noPriceThatDay = "No prices for this day" case someMissing = "%@ with no price" + /// Which price a chart shows, as this phone chose it. + case totalWithVAT = "TOTAL INCL. VAT" + case totalWithoutVAT = "TOTAL EXCL. VAT" + case spotWithVAT = "SPOT INCL. VAT" + case spotWithoutVAT = "SPOT EXCL. VAT" var text: String { Words.bundle.localizedString(forKey: rawValue, value: rawValue, table: "Price") } diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceShown.swift b/appleApp/FTWKit/Sources/FTWShared/PriceShown.swift new file mode 100644 index 0000000..a1310b3 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceShown.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Which price the app shows, a choice on this phone. Fredrik, 1 Nov 2026: +/// he wants it without grid fees, and wants to know whether it is with or +/// without VAT. The box sends the spot price (no fee, no VAT) and the +/// total it bills (fee, tax and VAT), and since `vatPermille` the rate it +/// taxed at, so the phone can show either with or without VAT. A box that +/// does not say its rate gets the price the phone has, and the label says +/// which that is: a price is never shown under the wrong name. +public struct PriceShown: Equatable, Sendable { + /// With the grid fee and energy tax: the total, not the spot price. + public var fees: Bool + public var vat: Bool + + /// Where the two choices are kept, on this phone. + public static let feesKey = "FTWPriceFees" + public static let vatKey = "FTWPriceVAT" + + public init(fees: Bool = true, vat: Bool = true) { + self.fees = fees + self.vat = vat + } + + /// What is shown, which the label names. + public enum Kind: Equatable, Sendable { + case totalWithVAT, totalWithoutVAT, spotWithVAT, spotWithoutVAT + } + + /// What this choice can show at the box's rate: without it, the total + /// keeps its VAT and the spot price goes without. + public func kind(vatPermille: Int?) -> Kind { + switch (fees, vat, vatPermille) { + case (true, true, _), (true, false, nil): return .totalWithVAT + case (true, false, _?): return .totalWithoutVAT + case (false, true, _?): return .spotWithVAT + case (false, _, _): return .spotWithoutVAT + } + } + + /// The price shown for one slot, in minor units per kWh. + public func minor(_ slot: PriceSlot, vatPermille: Int?) -> Double { + let rate = 1 + Double(vatPermille ?? 0) / 1000 + switch kind(vatPermille: vatPermille) { + case .totalWithVAT: return slot.totalMinor + case .totalWithoutVAT: return slot.totalMinor / rate + case .spotWithVAT: return slot.spotMinor * rate + case .spotWithoutVAT: return slot.spotMinor + } + } + + /// The slots with the shown price in `totalMinor`, so every chart that + /// draws the total draws what this phone chose. + public func apply(_ slots: [PriceSlot], vatPermille: Int?) -> [PriceSlot] { + guard kind(vatPermille: vatPermille) != .totalWithVAT else { return slots } + return slots.map { s in + var out = s + out.totalMinor = minor(s, vatPermille: vatPermille) + return out + } + } + + /// The same for a whole answer. + public func apply(_ prices: Prices) -> Prices { + var out = prices + out.slots = apply(prices.slots, vatPermille: prices.vatPermille) + return out + } + + /// The same in a line's words: "spot price only", "incl. VAT". + public static func words(_ kind: Kind) -> String { + switch kind { + case .totalWithVAT: return GlanceWord.inclGridFeeAndVAT.text + case .totalWithoutVAT: return GlanceWord.inclGridFee.text + case .spotWithVAT: return GlanceWord.inclVAT.text + case .spotWithoutVAT: return GlanceWord.spotOnly.text + } + } + + /// "SPOT INCL. VAT", over a chart. + public static func label(_ kind: Kind) -> String { + switch kind { + case .totalWithVAT: return PriceWord.totalWithVAT.text + case .totalWithoutVAT: return PriceWord.totalWithoutVAT.text + case .spotWithVAT: return PriceWord.spotWithVAT.text + case .spotWithoutVAT: return PriceWord.spotWithoutVAT.text + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Prices.swift b/appleApp/FTWKit/Sources/FTWShared/Prices.swift index 20bbd9f..e476b45 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Prices.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Prices.swift @@ -24,11 +24,16 @@ public struct Prices: Equatable, Sendable, Codable { /// The answer does not cover the window asked for. Which of the three /// shapes it is has to be read off the slots. public var stale: Bool + /// The VAT rate the box taxed `totalMinor` at, in permille: 250 is + /// 25 %. Nil from a box that does not say, and in a record from before + /// it was kept. + public var vatPermille: Int? - public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool) { + public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool, vatPermille: Int? = nil) { self.zone = zone self.currency = currency self.slots = slots self.stale = stale + self.vatPermille = vatPermille } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings index d389378..af1d55f 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings @@ -11,3 +11,9 @@ "Average %@ · lowest %@ at %@" = "Snitt %@ · lägst %@ kl %@"; "No prices for this day" = "Inga priser för det dygnet"; "%@ with no price" = "%@ utan pris"; + +/* Which price a chart shows, as this phone chose it. */ +"TOTAL INCL. VAT" = "TOTALPRIS INKL. MOMS"; +"TOTAL EXCL. VAT" = "TOTALPRIS EXKL. MOMS"; +"SPOT INCL. VAT" = "ELPRIS INKL. MOMS"; +"SPOT EXCL. VAT" = "ELPRIS EXKL. MOMS"; diff --git a/appleApp/FTWKit/Sources/FTWWire/Carrier/CloudWire.swift b/appleApp/FTWKit/Sources/FTWWire/Carrier/CloudWire.swift index 4ad0b7d..bb571e4 100644 --- a/appleApp/FTWKit/Sources/FTWWire/Carrier/CloudWire.swift +++ b/appleApp/FTWKit/Sources/FTWWire/Carrier/CloudWire.swift @@ -554,6 +554,9 @@ public enum CloudWire { public var spotMinor: [Int64?] /// What the household pays, tariff and VAT included. public var totalMinor: [Int64?] + /// The VAT rate in `totalMinor`, in permille; nil from a box that + /// does not say. + public var vatPermille: Int64? = nil } /// The box is there, and this is how old its readings are. It names the @@ -720,7 +723,8 @@ extension CloudWire.Prices { t0Ms: try f.int("t0Ms"), slotMs: try f.int("slotMs"), spotMinor: try f.list("spotMinor") { $0.isNull ? nil : try CloudWire.int($0, "spotMinor") }, - totalMinor: try f.list("totalMinor") { $0.isNull ? nil : try CloudWire.int($0, "totalMinor") } + totalMinor: try f.list("totalMinor") { $0.isNull ? nil : try CloudWire.int($0, "totalMinor") }, + vatPermille: try f.optionalInt("vatPermille") ) } } diff --git a/appleApp/FTWKit/Sources/FTWWire/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWWire/Protocol/Messages.swift index b482f37..ab8353f 100644 --- a/appleApp/FTWKit/Sources/FTWWire/Protocol/Messages.swift +++ b/appleApp/FTWKit/Sources/FTWWire/Protocol/Messages.swift @@ -216,7 +216,8 @@ extension Prices { totalMinor: s["totalMinor"]?.double ?? s["spotMinor"]?.double ?? 0 ) }, - stale: c["stale"]?.bool ?? false + stale: c["stale"]?.bool ?? false, + vatPermille: c["vatPermille"]?.int64.map { Int($0) } ) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PriceWireTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/PriceWireTests.swift new file mode 100644 index 0000000..3bcd2b4 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/PriceWireTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import FTWKit +@testable import FTWWire + +/// The box's answer to `price.get`, as srcfl/ftw#1949 sends it: the slots, +/// and the VAT rate the box taxed the totals at, in permille. +@Suite struct PriceWireTests { + func answer(_ extra: [(String, CBOR)]) -> CBOR { + .map([ + ("zone", .text("SE3")), ("currency", .text("SEK")), ("stale", .bool(false)), + ("slots", .array([.map([("startMs", .int(1_800_000_000_000)), ("durationMs", .int(900_000)), ("spotMinor", .int(249)), ("totalMinor", .int(462))])])), + ] + extra) + } + + @Test func theAnswerCarriesTheBoxsVATRate() { + let p = Prices(answer([("vatPermille", .int(255))])) + #expect(p.vatPermille == 255) + #expect(p.slots.first?.spotMinor == 249 && p.slots.first?.totalMinor == 462) + // Zero is a rate. + #expect(Prices(answer([("vatPermille", .int(0))])).vatPermille == 0) + } + + /// An older box leaves it out: the rate is unknown, not zero. + @Test func anOlderBoxSaysNoRate() { + #expect(Prices(answer([])).vatPermille == nil) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift index ff5042b..7751cc8 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift @@ -375,6 +375,7 @@ final class CloudSite { let prices = try w.box.seal(CloudWire.msgPrices, w.box.header(w.at) + [ ("zone", .text("SE3")), ("currency", .text("SEK")), ("t0Ms", .int(t0)), ("slotMs", .int(900_000)), ("spotMinor", .array([.int(42), .null, .int(40)])), ("totalMinor", .array([.int(118), .null, .int(115)])), + ("vatPermille", .int(250)), ]) let lp = try w.box.seal(CloudWire.msgLP, w.box.header(w.at) + [ ("controlRev", .int(3)), @@ -394,6 +395,8 @@ final class CloudSite { #expect(p.carrier == .cloud) #expect(p.value.prices.slots.count == 2) #expect(p.value.prices.slots.first?.totalMinor == 118) + // The box's VAT rate rides with them, for the price this phone shows. + #expect(p.value.prices.vatPermille == 250) let car = try #require(w.shared.read(CarEntry.self, siteId: "s1")) #expect(car.carrier == .cloud) #expect(car.value.cars.first?.socPct == 43) diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceShownTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceShownTests.swift new file mode 100644 index 0000000..3fc1adf --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceShownTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The price this phone shows. The box's numbers are Erik's morning: a +/// 462 öre total is 249 öre spot, 121 öre grid fee and tax, and 25 % VAT. +@Suite struct PriceShownTests { + let slot = PriceSlot(startMs: 0, durationMs: 900_000, spotMinor: 249, totalMinor: 462) + + @Test func eachChoiceAtTheBoxsRate() { + func shown(_ fees: Bool, _ vat: Bool) -> Double { PriceShown(fees: fees, vat: vat).minor(slot, vatPermille: 250) } + #expect(shown(true, true) == 462) + #expect(abs(shown(true, false) - 369.6) < 1e-9) + #expect(abs(shown(false, true) - 311.25) < 1e-9) + #expect(shown(false, false) == 249) + } + + /// A box that does not say its rate: the total keeps its VAT and the + /// spot price goes without, and the label says so. + @Test func withoutTheRateTheLabelSaysWhatIsShown() { + #expect(PriceShown(fees: true, vat: false).kind(vatPermille: nil) == .totalWithVAT) + #expect(PriceShown(fees: false, vat: true).kind(vatPermille: nil) == .spotWithoutVAT) + #expect(PriceShown(fees: false, vat: true).minor(slot, vatPermille: nil) == 249) + #expect(PriceShown.label(.spotWithoutVAT) == "SPOT EXCL. VAT") + // Zero VAT is a rate, not a missing one. + #expect(PriceShown(fees: false, vat: true).kind(vatPermille: 0) == .spotWithVAT) + } + + /// The charts draw the total, so the choice goes into it; the default + /// leaves the answer as the box sent it. + @Test func theChoiceGoesIntoTheTotal() { + let prices = Prices(zone: "SE3", currency: "SEK", slots: [slot], stale: false, vatPermille: 250) + #expect(PriceShown().apply(prices) == prices) + #expect(PriceShown(fees: false, vat: false).apply(prices).slots[0].totalMinor == 249) + #expect(PriceShown(fees: false, vat: false).apply(prices).slots[0].spotMinor == 249) + } + + /// An answer kept from before the rate was on the wire still reads. + @Test func aRecordWithoutTheRateStillReads() throws { + let prices = Prices(zone: "SE3", currency: "SEK", slots: [slot], stale: false, vatPermille: 250) + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(prices)) as? [String: Any]) + #expect(json["vatPermille"] as? Int == 250) + json["vatPermille"] = nil + let old = try JSONDecoder().decode(Prices.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(old.vatPermille == nil && old.slots == prices.slots) + } +}