receipts

package
v0.41.10 Latest Latest
Warning

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

Go to latest
Published: Nov 12, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package receipts syncs what Rivian actually billed for each charging session (its Charging history) next to our own estimate.

Index

Constants

View Source
const (
	EnergyOff     = "off"
	EnergyObserve = "observe"
	EnergyApply   = "apply"
)

Energy modes (RIVOLT_RECEIPT_ENERGY): Observe measures and counts only, Apply also writes.

View Source
const (
	OutcomeApplied    = "applied"
	OutcomeWouldApply = "would_apply"
	OutcomeHome       = "home"
	OutcomeNoDetail   = "no_detail"
	OutcomeNoEnergy   = "no_energy"
	OutcomeOpen       = "open"
	OutcomeBelow      = "below_recorded"
	OutcomeAbove      = "above_tail_limit"
)

Outcomes of looking at one matched receipt, for the metrics.

View Source
const (
	// PaidMinSessions is how many usable receipts a network needs before a figure is
	// shown; fewer is noise (one off-peak session, one promotion).
	PaidMinSessions = 3
	// PaidMaxSessions caps it at the most recent sessions, so an old price does not
	// outweigh the current one.
	PaidMaxSessions = 10
	// PaidMaxAge drops receipts older than this: a price paid long ago is not
	// today's price.
	PaidMaxAge = 365 * 24 * time.Hour
)
View Source
const (
	TriggerForward = "forward"
	TriggerSweep   = "sweep"
	TriggerClose   = "close"
)

Triggers and outcomes of a summaries pass, for rivolt_receipts_sync_total. Outcomes: ok (the pass ran), skipped (opted out, no receipt source, or the 30 min claim is held by an earlier pass on any pod), not_owner (another pod owns the user's group), error.

View Source
const SourceReceipt = "rivian_receipt"

SourceReceipt is the metadata energy_source of a charge whose energy came from its receipt.

Variables

View Source
var (
	Triggers = []string{TriggerForward, TriggerSweep, TriggerClose}
	Outcomes = []string{"ok", "skipped", "not_owner", "error"}
)

Functions

func PaidRates added in v0.38.7

func PaidRates(rs []Receipt, now time.Time, currency string) map[string]PaidRate

PaidRates computes the figures from a user's receipts: per network (the vendor and charger name matched to the network table), over the most recent PaidMaxSessions receipts that are within PaidMaxAge, not at a home charger, in the given currency, with a positive amount and energy. A network with fewer than PaidMinSessions such receipts has no entry. Pure.

Types

type ChargeRef

type ChargeRef struct {
	ID        uuid.UUID
	StartedAt time.Time
	EndedAt   time.Time
	EnergyKWh float64
}

ChargeRef is the part of a charge row the matcher looks at.

type EnergyCand added in v0.41.10

type EnergyCand struct {
	ChargeID   uuid.UUID
	ReceiptID  uuid.UUID
	TxnID      string
	FinalState string
	ChargeEnd  time.Time
	MaxPowerKW float64
	// Energy is the charge's energy now; Recorded is what the recorder saved first (the metadata
	// original once a receipt value is in, else Energy); AvgRecorded and CostRecorded the same for
	// the average power and the cost.
	Energy, Recorded, AvgPower float64
	AvgRecorded                *float64
	Cost                       *float64
	CostRecorded               *float64
	CostSource                 string
	PricePerKWh                float64
	FromReceipt                bool

	ReceiptStart, ReceiptEnd time.Time
	ReceiptEnergy            *float64
	IsHome                   *bool
	DetailFetched            bool
}

EnergyCand is one matched receipt next to its charge, as the check needs them.

type EnergyUpdate added in v0.41.10

type EnergyUpdate struct {
	Energy      float64
	AvgPower    float64
	Cost        *float64 // nil: leave the cost alone
	Meta        map[string]any
	ExpectedNow float64
}

EnergyUpdate is what to write for a candidate that passed.

func EvaluateEnergy added in v0.41.10

func EvaluateEnergy(c EnergyCand) (outcome string, deltaPct float64, up *EnergyUpdate)

EvaluateEnergy checks one candidate. outcome is OutcomeWouldApply when the receipt's energy should be taken (nil update means nothing to write: it already is), deltaPct the receipt's difference from what we recorded as a percentage of it (0 when not computed).

type PaidRate added in v0.38.7

type PaidRate struct {
	Slug     string
	Currency string
	// PerKWh is total paid over total energy across Sessions receipts (energy-weighted,
	// not an average of per-session prices).
	PerKWh   float64
	Sessions int
}

PaidRate is one network's figure for one user.

type Pair

type Pair struct {
	ReceiptID uuid.UUID
	ChargeID  uuid.UUID
}

Pair says which charge a receipt belongs to.

func Match

func Match(charges []ChargeRef, receipts []Receipt) []Pair

Match pairs unmatched receipts with unmatched charges one to one.

A receipt and a charge are candidates when both their starts and their ends are within 5 minutes, or when one of the two is within 5 minutes and the energies agree (a long trickle session can end at different moments on the two clocks). When both energies are known and disagree the pair is never a candidate, however close the times. Candidates are taken best first by |start gap| + |end gap|.

type Receipt

type Receipt struct {
	ID            uuid.UUID
	TransactionID string
	StartedAt     time.Time
	EndedAt       time.Time
	TotalCost     *float64
	Currency      string
	EnergyKWh     *float64
	RangeAddedKm  *float64
	Vendor        string
	City          string
	ChargerName   string
	IsHome        *bool
	DetailFetched bool
	ChargeID      *uuid.UUID
}

Receipt is one Rivian charging-history row.

type Result

type Result struct {
	Skipped     bool // claimed by another run within the interval, or not this pod's user
	NotOwner    bool // skipped because another pod owns the user's group (lead mode)
	Fetched     int  // summaries returned
	Matched     int  // new receipt-to-charge links
	Details     int  // detail calls that succeeded
	DetailsLeft bool
}

Result is what one Sync did, for logs and tests.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the Postgres side: rivian_receipts, rivian_receipt_sync and the charges.receipt_* columns (migration 0055). It never touches a charge's cost, cost_source or price_per_kwh.

func NewStore

func NewStore(d *sql.DB) *Store

NewStore wraps a pool.

func (*Store) Apply

func (s *Store) Apply(ctx context.Context, uid uuid.UUID, pairs []Pair) error

Apply links each pair: the receipt gets charge_id, the charge gets the billed amount in receipt_*. A charge's cost, cost_source and price_per_kwh are left alone.

func (*Store) ApplyEnergy added in v0.41.10

func (s *Store) ApplyEnergy(ctx context.Context, uid uuid.UUID, c EnergyCand, up EnergyUpdate) (bool, error)

ApplyEnergy writes one update to the charge, only while it is still closed, still linked to the receipt and still holding the energy the check saw. It reports whether a row changed.

func (*Store) CandidateCharges

func (s *Store) CandidateCharges(ctx context.Context, uid uuid.UUID, from, to time.Time) ([]ChargeRef, error)

CandidateCharges lists the user's charges without a receipt whose window touches [from, to].

func (*Store) ClaimDetails

func (s *Store) ClaimDetails(ctx context.Context, uid uuid.UUID, minInterval time.Duration) (bool, error)

ClaimDetails is the same for a details-only pass (no summaries call).

func (*Store) ClaimSummaries

func (s *Store) ClaimSummaries(ctx context.Context, uid uuid.UUID, minInterval time.Duration) (bool, error)

ClaimSummaries records an attempt to fetch the user's summaries and reports whether this caller won it: no attempt in the last minInterval, across pods and restarts.

func (*Store) EnergyCandidates added in v0.41.10

func (s *Store) EnergyCandidates(ctx context.Context, uid uuid.UUID, since time.Time) ([]EnergyCand, error)

EnergyCandidates lists the user's receipts matched since `since` with their charges.

func (*Store) ListAll

func (s *Store) ListAll(ctx context.Context, uid uuid.UUID) ([]Receipt, error)

ListAll is every receipt of the user, for the data export.

func (*Store) MarkResult

func (s *Store) MarkResult(ctx context.Context, uid uuid.UUID, runErr error) error

MarkResult stores the outcome of a claimed attempt. The error text is kept short and is only ever our own classification, never a response body.

func (*Store) NeedDetail

func (s *Store) NeedDetail(ctx context.Context, uid uuid.UUID, limit int) ([]string, error)

NeedDetail returns up to limit transaction ids of matched receipts whose charger details were never fetched.

func (*Store) PaidRates added in v0.38.7

func (s *Store) PaidRates(ctx context.Context, uid uuid.UUID, now time.Time, currency string) (map[string]PaidRate, error)

PaidRates reads the user's receipts and returns their paid $/kWh per network. Empty for a user without receipts (they must have opted in for any to exist).

func (*Store) Purge added in v0.36.10

func (s *Store) Purge(ctx context.Context, uid uuid.UUID) error

Purge removes everything the receipts sync stored for a user: the receipts, the sync clock, and the receipt_* columns on their charges. The estimate on the charge is untouched. Used when the user opts out.

func (*Store) PurgeNotOptedIn added in v0.36.10

func (s *Store) PurgeNotOptedIn(ctx context.Context) (int64, error)

PurgeNotOptedIn is the daily backstop for Purge: it removes the receipts of every user who is not opted in, catching a pass that saved rows after the user opted out. It returns how many receipts it deleted.

func (*Store) RefreshMatched

func (s *Store) RefreshMatched(ctx context.Context, uid uuid.UUID) error

RefreshMatched copies a changed amount or currency from a receipt to the charge it is linked to.

func (*Store) SaveDetail

func (s *Store) SaveDetail(ctx context.Context, uid uuid.UUID, txn string, c *rivian.SessionCharger) error

SaveDetail stores the charger fields. For a home charger only the flag is kept: its name can be the owner's own place. A nil charger still marks the row fetched so it is not asked again.

func (*Store) Unmatched

func (s *Store) Unmatched(ctx context.Context, uid uuid.UUID) ([]Receipt, error)

Unmatched lists the user's receipts that no charge claims yet.

func (*Store) UpsertReceipts

func (s *Store) UpsertReceipts(ctx context.Context, uid uuid.UUID, in []rivian.SessionSummary) error

UpsertReceipts stores the summaries, refreshing amounts of rows we already have (a receipt can finalize later) and keeping detail and match state.

type Syncer

type Syncer struct {
	// contains filtered or unexported fields
}

Syncer pulls receipts for users whose flag is on.

func NewSyncer

func NewSyncer(st *Store, accounts rivian.AccountRegistry, enabled func(uuid.UUID) bool, users func(context.Context) ([]uuid.UUID, error), logger *slog.Logger) *Syncer

NewSyncer wires a Syncer. enabled gates a user (the rivian_receipts flag); users lists the users with a Rivian session for the sweep.

func (*Syncer) OnChargeClose

func (s *Syncer) OnChargeClose(uid uuid.UUID)

OnChargeClose schedules a sync for the user after closeDelay. One timer per user: further closes while it is pending are absorbed.

func (*Syncer) Run

func (s *Syncer) Run(ctx context.Context)

Run sweeps until ctx ends: every sweepEvery it syncs each enabled user once a day (summaries) and gives users with unfetched details a details-only pass, one user at a time, userPace apart.

func (*Syncer) SetEnergy added in v0.41.10

func (s *Syncer) SetEnergy(mode string, observe func(outcome string, deltaPct float64))

SetEnergy sets the energy mode and the observer of its outcomes (outcome, delta percent; the percent is NaN-free and 0 when not computed). Default (never called) is off.

func (*Syncer) SetFence added in v0.40.13

func (s *Syncer) SetFence(f UserFencer)

SetFence makes the sync run only on the pod that owns the user's group, with its writes guarded by that lease (lead mode); with lead mode off the store says Off and nothing changes.

func (*Syncer) SetObserve added in v0.41.10

func (s *Syncer) SetObserve(f func(trigger, outcome string))

SetObserve installs the counter hook for summaries passes (before Run and before the first charge close).

func (*Syncer) Sync

func (s *Syncer) Sync(ctx context.Context, uid uuid.UUID, minInterval time.Duration, detailsOnly bool) (Result, error)

Sync runs one pass for a user: unless detailsOnly, fetch the summaries, store them and match them to charges; then fetch up to maxDetailPerRun charger details for matched receipts. minInterval is the claim window.

func (*Syncer) SyncOnRequest added in v0.41.10

func (s *Syncer) SyncOnRequest(ctx context.Context, uid uuid.UUID) (Result, error)

SyncOnRequest is the owner's side of a forwarded receipts_sync: one summaries-and-match pass (no detail calls, so it fits the kind timeout) with the close-time claim window, so the cross-pod claim lets at most one summaries call per user through every 30 min however often it is asked for. The fence check inside Sync comes before the claim.

type UserFencer added in v0.40.13

type UserFencer interface {
	FenceUser(ctx context.Context, uid string) (fence.Token, fence.State, error)
}

UserFencer resolves a user to the fence state of their group: the lease store implements it.

Jump to

Keyboard shortcuts

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