Documentation
¶
Overview ¶
Package ledger builds the server-side money statement (the Money tab and, eventually, every cost/energy total in the SPA) from recorded charges and drives.
Everything here is read-only and pure: Build takes rows already loaded from Postgres (see Load) and returns the response. Three rules keep the numbers honest:
- Charges are the ledger. "Spend" is what was paid for energy; driving cost is spend ÷ miles, never a sum of per-drive guesses.
- Overlapping duplicates (two recorders on one vehicle, or an import overlapping live data) are resolved at query time and excluded from every total. No rows are modified.
- Unknown stays unknown: unpriced sessions, missing energy and inferred provenance are counted and surfaced, not papered over.
The wire contract lives in the Money tab's ledger-contract.md (v1).
Index ¶
- Constants
- func CostSource(c Charge, cfg Config) (string, bool)
- func EffectiveCharges(cs []Charge) ([]Charge, []Superseded)
- func Load(ctx context.Context, d *sql.DB, userID uuid.UUID, vehicleID string, ...) ([]Charge, []Drive, error)
- func LoadWindow(ps ...Period) (time.Time, time.Time)
- type Charge
- type Classification
- type Config
- type DataQuality
- type Drive
- type Driving
- type Energy
- type Input
- type KindLine
- type LatLon
- type MonthSummary
- type Monthly
- type Period
- type Range
- type Receipt
- type ReceiptSession
- type Spend
- type Superseded
- type Trip
- type TripPlan
- type TripsResponse
Constants ¶
const ( SourceRivian = "rivian" SourceHomeRate = "home_rate" SourceNetworkEstimate = "network_estimate" SourceUser = "user" SourceHomeRateMisapplied = "home_rate_misapplied" SourceUnknown = "unknown" SourceMixed = "mixed" )
Cost sources. Rows closed by the pricing-aware recorder persist one of rivian / home_rate / network_estimate / user in charges.cost_source (migration 0040). Legacy rows (NULL) are derived at query time, which adds home_rate_misapplied: the old recorder's home rate stamped on a DC fast session, shown as "estimate pending" and kept out of spend until `rivolt reprice` backfills it.
const ( FlagFragmentsFolded = "fragments_folded" FlagHomeRateOnDCFC = "home_rate_on_dcfc" FlagHomeRateAway = "home_rate_away" FlagUnpriced = "unpriced" FlagNullEnergy = "null_energy" FlagOpenSession = "open_session" FlagAbandoned = "abandoned" FlagContinuesNextMonth = "continues_next_month" FlagContinuedFromPrev = "continued_from_previous_month" FlagCurrencyMismatch = "currency_mismatch" FlagEstimatePending = "estimate_pending" // FlagSuperseded marks a row the ledger drops as an overlapping // duplicate of another (EffectiveCharges); it isn't in any total. FlagSuperseded = "superseded" // FlagFree marks a session the user priced at $0 (cost_source=user, // cost 0): priced, counted in spend at zero, shown as "Free". FlagFree = "free" )
Flags on receipts and sessions.
const CrumbKWh = 0.05
CrumbKWh is the energy below which a charge session is a crumb (a plug-in blip, an abandoned stub): nothing to price, counted as null_energy rather than "needs a price". Seen on preview: a 4.7e-7 kWh abandoned row counted as unpriced.
const FastMinKW = 50.0
FastMinKW is the session peak at or above which a charge is DC fast charging. Same threshold as analytics.FastMinKW (the Charges page's "Fast" bucket) so the two surfaces agree.
const HomeRadiusKM = rivian.HomeRadiusM / 1000
HomeRadiusKM is how close to the saved home location a session must start to count as "home". Matches the ~200 m DBSCAN epsilon the Charges page uses, with a little GPS slack.
const MinMilesForCostPerMile = 100.0
MinMilesForCostPerMile is the driven distance below which driving.cost_per_mile is null: with only a few miles, one charge dominates and the ratio is meaningless (D2 F6).
const TripMaxParked = 12 * time.Hour
TripMaxParked is the longest a car can sit parked at home (or where we can't tell) inside one outing; a longer park ends it. Away from home a multi-night stay stays one trip (arriving home ends it), up to TripMaxParkedAway as a safety valve.
const TripMaxParkedAway = 14 * 24 * time.Hour
TripMaxParkedAway caps a park away from home inside one trip.
const TripMinKMFromHome = 150.0
TripMinKMFromHome is how far from home an outing must go to count as a trip (when the home location is known).
const Version = 1
Version is the response contract version. Additive changes keep it; breaking changes bump it.
Variables ¶
This section is empty.
Functions ¶
func CostSource ¶
CostSource reports where a session's cost came from and whether the answer was derived at query time rather than read from the row.
- A persisted rivian / network_estimate / user source wins (a row with no cost is still unknown).
- Legacy rows (NULL source) with no cost are unknown.
- A persisted home_rate wins, except AC away from home → unknown (owner decision 2026-10-13: away AC is unknown until priced).
- A legacy rate within homeRateTolerance of the user's home rate: DC fast charging → home_rate_misapplied (estimate pending); AC away from home → unknown, needs a price; otherwise → home_rate.
- Any other legacy price → rivian (billed, or a manual edit / an older home rate).
func EffectiveCharges ¶
func EffectiveCharges(cs []Charge) ([]Charge, []Superseded)
EffectiveCharges resolves overlapping duplicates per vehicle. Sessions are accepted in priority order (live over imports, then has energy, has a cost, longer, created earlier, id); a session overlapping one already accepted is superseded by it. Output is sorted by start.
func Load ¶
func Load(ctx context.Context, d *sql.DB, userID uuid.UUID, vehicleID string, from, to time.Time) ([]Charge, []Drive, error)
Load reads the user's charges and drives whose time range intersects [from, to), optionally narrowed to one Rivian vehicle id. Read-only: plain SELECTs filtered by user_id (RLS is the backstop, not the filter). Rows keep NULLs as nil so the ledger can tell "not recorded" from zero.
Types ¶
type Charge ¶
type Charge struct {
ID string // charges.external_id, the id /api/charges exposes
VehicleID string // Rivian vehicle id
StartedAt time.Time
EndedAt time.Time
StartSoC *float64
EndSoC *float64
EnergyKWh *float64
PeakKW *float64
Cost *float64
Currency string
PricePerKWh *float64
FinalState string
Source string
Lat, Lon *float64
CreatedAt time.Time
// ActiveSeconds is charges.active_seconds (time actually drawing
// power); nil when not recorded. Feeds the shared DCFC rule.
ActiveSeconds *float64
// Place is charges.place ("home" / "away") recorded with the row
// (migration 0044); "" when not recorded.
Place string
// CostSource is charges.cost_source; "" when NULL (legacy row) or
// the column doesn't exist yet.
CostSource string
// Network is charges.network (e.g. "Electrify America") for
// network-estimated sessions; "" when unknown.
Network string
}
Charge is the projection of a charges row the ledger needs. Pointer fields are nullable columns: nil means "not recorded", which the ledger keeps distinct from zero.
func FromCharge ¶
FromCharge converts a charges.Charge as the store lists it (zeros for NULL columns) into the ledger's nullable shape. A zero cost stays 0 only on user-priced rows, where it means Free.
type Classification ¶
type Classification struct {
CostSource string `json:"cost_source_effective"`
CostSourceDerived bool `json:"cost_source_derived"`
Kind string `json:"kind"`
Place string `json:"place"`
KMFromHome *float64 `json:"km_from_home"`
// CountsInSpend is true when the session's cost is part of
// spend.total (priced, same currency).
CountsInSpend bool `json:"counts_in_spend"`
Flags []string `json:"ledger_flags"`
}
Classification is the ledger's view of one charge: the same cost source, kind, place and flags the Money tab uses, for other surfaces (the Charges list, the charge page, the Activity feed) so they don't re-derive them with their own rules.
func Classify ¶
func Classify(c Charge, cfg Config) Classification
Classify returns the ledger's classification of c under cfg.
func ClassifyAll ¶
func ClassifyAll(cs []Charge, cfg Config) []Classification
ClassifyAll is Classify over a list, with the ledger's dedupe applied: a row EffectiveCharges drops as an overlapping duplicate gets CountsInSpend=false and the superseded flag, so a list built from it agrees with the Money tab about what's in spend. Results are in the input order.
type Config ¶
type Config struct {
// Currency is the user's home currency (ISO-4217). Sessions in
// another currency are excluded from spend and flagged.
Currency string
// HomeRate is the user's configured home $/kWh; 0 = not set.
HomeRate float64
// Home is the saved home location; nil = not set.
Home *LatLon
}
Config is the per-user context the statement is priced in.
type DataQuality ¶
type DataQuality struct {
// CostSourceInferred is true when any session in the month had its
// cost_source derived (legacy NULL); CostSourceDerivedSessions counts
// them.
CostSourceInferred bool `json:"cost_source_inferred"`
CostSourceDerivedSessions int `json:"cost_source_derived_sessions"`
SupersededExcluded int `json:"superseded_excluded"`
Superseded []Superseded `json:"superseded,omitempty"`
DrivesSupersededExcluded int `json:"drives_superseded_excluded"`
OpenSessions int `json:"open_sessions"`
HomeRateOnDCFC int `json:"home_rate_on_dcfc"`
HomeRateAway int `json:"home_rate_away"`
CurrencyMismatchExcluded int `json:"currency_mismatch_excluded"`
}
DataQuality carries the counts behind the Money tab's quality notes.
type Drive ¶
type Drive struct {
ID string
VehicleID string
StartedAt time.Time
EndedAt time.Time
DistanceMi *float64
EnergyKWh *float64
Source string
CreatedAt time.Time
// Start and end positions; nil when not recorded. Used only to
// measure distance from home (trips); never returned.
StartLat, StartLon *float64
EndLat, EndLon *float64
}
Drive is the projection of a drives row the ledger needs.
type Driving ¶
type Driving struct {
Drives int `json:"drives"`
Miles float64 `json:"miles"`
MilesWithEnergy float64 `json:"miles_with_energy"`
KWhUsedKnown float64 `json:"kwh_used_known"`
Coverage *float64 `json:"coverage"`
CostPerMile *float64 `json:"cost_per_mile"`
CostPerMileBasis string `json:"cost_per_mile_basis"`
}
Driving summarises drives and the per-mile allocation of spend.
type Energy ¶
type Energy struct {
Sessions int `json:"sessions"`
KWhAdded float64 `json:"kwh_added"`
NullEnergySessions int `json:"null_energy_sessions"`
ByKind map[string]KindLine `json:"by_kind"`
ByPlace map[string]KindLine `json:"by_place"`
// ByPlaceKind is the place × kind cross-tab of the same per-session
// classification: summed over places it equals ByKind, over kinds
// ByPlace. All six cells are always present.
ByPlaceKind map[string]map[string]KindLine `json:"by_place_kind"`
}
Energy is energy added by charging.
type Input ¶
Input is everything Build needs for one or more months: rows loaded over LoadWindow(periods...).
type KindLine ¶
type KindLine struct {
Sessions int `json:"sessions"`
KWh float64 `json:"kwh"`
Cost float64 `json:"cost"`
PeakKW *float64 `json:"peak_kw"`
}
KindLine is one by_kind / by_place bucket. Sessions are classified one by one (session peak for kind, session location for place), so each map's lines sum to the Energy totals.
type MonthSummary ¶
MonthSummary is a Monthly without receipts, for GET /api/ledger/summary. The outer Receipts field shadows the embedded one in JSON and is always empty, so the key is omitted.
type Monthly ¶
type Monthly struct {
Version int `json:"version"`
Period string `json:"period"`
TZ string `json:"tz"`
TZSource string `json:"tz_source"`
Range Range `json:"range"`
Currency string `json:"currency"`
VehicleID *string `json:"vehicle_id"`
IsCurrentMonth bool `json:"is_current_month"`
FirstActivityAt *time.Time `json:"first_activity_at"`
LastActivityAt *time.Time `json:"last_activity_at"`
Spend Spend `json:"spend"`
Energy Energy `json:"energy"`
Driving Driving `json:"driving"`
Receipts []Receipt `json:"receipts"`
DataQuality DataQuality `json:"data_quality"`
}
Monthly is the GET /api/ledger/monthly response.
type Period ¶
type Period struct {
Month string // YYYY-MM
Loc *time.Location
Start time.Time // inclusive, UTC instant
End time.Time // exclusive, UTC instant
}
Period is one statement month in a location.
func LastMonths ¶
LastMonths returns the n months ending with the one containing now, oldest first.
type Receipt ¶
type Receipt struct {
ReceiptID string `json:"receipt_id"`
VehicleID string `json:"vehicle_id"`
Kind string `json:"kind"`
Place string `json:"place"`
KMFromHome *float64 `json:"km_from_home"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
LocalDate string `json:"local_date"`
SpansDays int `json:"spans_days"`
SoCStart *float64 `json:"soc_start"`
SoCEnd *float64 `json:"soc_end"`
KWh float64 `json:"kwh"`
PeakKW *float64 `json:"peak_kw"`
Cost *float64 `json:"cost"`
CostSource string `json:"cost_source"`
Network *string `json:"network"`
Rate *float64 `json:"rate"`
SessionCount int `json:"session_count"`
UnpricedSessions int `json:"unpriced_sessions"`
Flags []string `json:"flags"`
Sessions []ReceiptSession `json:"sessions"`
}
Receipt is one plug-in: consecutive charges with no drive between.
type ReceiptSession ¶
type ReceiptSession struct {
ChargeID string `json:"charge_id"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
SoCStart *float64 `json:"soc_start"`
SoCEnd *float64 `json:"soc_end"`
KWh *float64 `json:"kwh"`
PeakKW *float64 `json:"peak_kw"`
Cost *float64 `json:"cost"`
PricePerKWh *float64 `json:"price_per_kwh"`
CostSource string `json:"cost_source"`
// CostSourceDerived is true when cost_source was derived at query
// time (legacy row) rather than persisted by the recorder.
CostSourceDerived bool `json:"cost_source_derived"`
Network *string `json:"network"`
Kind string `json:"kind"`
Place string `json:"place"`
FinalState string `json:"final_state"`
Source string `json:"source"`
Flags []string `json:"flags"`
}
ReceiptSession is one charge inside a receipt.
type Spend ¶
type Spend struct {
Total float64 `json:"total"`
BySource map[string]float64 `json:"by_source"`
PricedSessions int `json:"priced_sessions"`
UnpricedSessions int `json:"unpriced_sessions"`
UnpricedKWh float64 `json:"unpriced_kwh"`
UnpricedHomeRateEstimate *float64 `json:"unpriced_home_rate_estimate"`
EstimatePendingSessions int `json:"estimate_pending_sessions"`
EstimatePendingKWh float64 `json:"estimate_pending_kwh"`
}
Spend is money paid for energy.
type Superseded ¶
type Superseded struct {
ChargeID string `json:"charge_id"`
SupersededBy string `json:"superseded_by"`
}
Superseded names a duplicate excluded in favour of its winner.
type Trip ¶
type Trip struct {
TripID string `json:"trip_id"`
VehicleID string `json:"vehicle_id"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
Miles float64 `json:"miles"`
DriveIDs []string `json:"drive_ids"`
ChargeIDs []string `json:"charge_ids"`
FastSessions int `json:"fast_sessions"`
MaxKMFromHome *float64 `json:"max_km_from_home"`
KWhUsed float64 `json:"kwh_used"`
KWhAdded float64 `json:"kwh_added"`
Cost float64 `json:"cost"`
CostSourceMix map[string]float64 `json:"cost_source_mix"`
UnpricedSessions int `json:"unpriced_sessions"`
EfficiencyMiKWh *float64 `json:"efficiency_mi_per_kwh"`
Plan *TripPlan `json:"plan"`
}
Trip is one auto-detected trip.
type TripPlan ¶
type TripPlan struct {
PlanID string `json:"plan_id"`
PlannedCost float64 `json:"planned_cost"`
PlannedArrivalAt time.Time `json:"planned_arrival_at"`
PlannedMiles float64 `json:"planned_miles"`
}
TripPlan links a saved planned trip. Always null in v1.
type TripsResponse ¶
type TripsResponse struct {
Version int `json:"version"`
TZ string `json:"tz"`
Currency string `json:"currency"`
Trips []Trip `json:"trips"`
}
TripsResponse is the GET /api/trips body.
func BuildTrips ¶
BuildTrips detects trips among the effective (deduped) drives and charges in in, returning those that start in [from, to), newest first. in should be loaded over a window padded beyond [from, to) so a trip straddling the edges is seen whole.