ledger

package
v0.32.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 11 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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).

View Source
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.

View Source
const TripMaxParkedAway = 14 * 24 * time.Hour

TripMaxParkedAway caps a park away from home inside one trip.

View Source
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).

View Source
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

func CostSource(c Charge, cfg Config) (string, bool)

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.

func LoadWindow

func LoadWindow(ps ...Period) (time.Time, time.Time)

LoadWindow is the [from, to) range Load must cover for the periods, padded so boundary-straddling sessions are seen.

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

func FromCharge(c charges.Charge) Charge

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.

func EffectiveDrives

func EffectiveDrives(ds []Drive) ([]Drive, map[string]string)

EffectiveDrives is EffectiveCharges for drives. Returns the kept drives sorted by start and, for each superseded drive id, its winner.

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

type Input struct {
	Charges []Charge
	Drives  []Drive
	Config  Config
	Now     time.Time
}

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 LatLon

type LatLon struct{ Lat, Lon float64 }

LatLon is a WGS84 point.

type MonthSummary

type MonthSummary struct {
	Monthly
	Receipts []Receipt `json:"receipts,omitempty"`
}

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.

func Build

func Build(in Input, p Period, withReceipts bool) Monthly

Build produces the statement for p. withReceipts=false yields the month summary shape (no receipts, no superseded list).

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

func LastMonths(now time.Time, n int, loc *time.Location) []Period

LastMonths returns the n months ending with the one containing now, oldest first.

func ParsePeriod

func ParsePeriod(month string, loc *time.Location) (Period, error)

ParsePeriod parses YYYY-MM in loc.

type Range

type Range struct {
	Start time.Time `json:"start"`
	End   time.Time `json:"end"`
}

Range is a [start, end) UTC interval.

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"`
	EstimatedShare           *float64           `json:"estimated_share"`
	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"`
	EstimatedShare   *float64           `json:"estimated_share"`
	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

func BuildTrips(in Input, from, to time.Time, loc *time.Location) TripsResponse

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL