Help us shape the future of Go! Take the 2026 Go Developer Survey

statement

package
v0.39.1 Latest Latest
Warning

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

Go to latest
Published: Oct 24, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package statement builds and sends the monthly statement email (scratchpad/statement-email-spec.md): pure rendering from the ledger's own Monthly, the signed unsubscribe token, the preference and send-state store, and the idempotent hourly job.

Index

Constants

View Source
const (
	RetryWindow = 3 * 24 * time.Hour
	MaxAttempts = 3
)

RetryWindow is how long a failed send may be retried before the period is given up on, and MaxAttempts caps the retries within it. (The send window itself is the user's local days 1-2, so in practice it is the shorter bound.)

View Source
const SendRetention = 13 * 31 * 24 * time.Hour

SendRetention is how long statement_email_sends rows are kept.

View Source
const TokenTTL = 90 * 24 * time.Hour

TokenTTL is how long an unsubscribe link in an email works.

Variables

View Source
var ErrBadToken = errors.New("statement: invalid or expired token")

ErrBadToken is returned for any malformed, tampered or expired token; callers never learn which.

Functions

func Due

func Due(now time.Time, tz string) (period string, loc *time.Location, due bool)

Due reports whether a user in tz is due at now, and the period (the previous full local month) to send.

func Money

func Money(v float64, currency string) string

Money formats an amount in the statement currency.

func MonthName

func MonthName(period string) string

MonthName renders "September 2026" for a YYYY-MM period.

func PreviousMonth

func PreviousMonth(t time.Time, loc *time.Location) string

Period returns the YYYY-MM of the month before the one containing t, in loc.

func Silent

func Silent(m ledger.Monthly) bool

Silent reports the no-email condition (spec §1.5).

Types

type Candidate

type Candidate struct {
	UserID    uuid.UUID
	Timezone  string
	Email     string
	FirstName string
}

Candidate is an opted-in user the job may send to.

type Input

type Input struct {
	Period    string // YYYY-MM, the month being reported
	FirstName string
	Currency  string
	Month     ledger.Monthly
	// Prev is the previous month's statement, or nil when it could not
	// be read.
	Prev  *ledger.Monthly
	Links Links
	// Savings is the month's off-peak saving estimate, or nil. It is only ever
	// built for a user who is already getting this email.
	Savings *ledger.Savings
}

Input is everything the template needs.

type Job

type Job struct {
	Store   Store
	Mailer  Mailer
	Monthly MonthlyFunc
	Savings SavingsFunc // optional
	Signer  *Signer
	// Enabled reports whether the operator flag covers the user.
	Enabled func(userID string) bool
	BaseURL string
	Logger  *slog.Logger
	// Now and Pause are overridable for tests. Pause is called between
	// sends (the throttle); nil uses a 500 ms sleep (2 sends/s).
	Now   func() time.Time
	Pause func(ctx context.Context)
}

Job sends the monthly statement emails. It is safe to run on every replica: the (user, period) claim row is the idempotency key.

func (*Job) Build

func (j *Job) Build(c Candidate, period string, cur ledger.Monthly, prev *ledger.Monthly) (email.Message, bool)

Build renders the message for a candidate (also used by the preview endpoint so preview and send can never differ). ok is false for a silent month.

func (*Job) BuildWith added in v0.38.10

func (j *Job) BuildWith(c Candidate, period string, cur ledger.Monthly, prev *ledger.Monthly, sv *ledger.Savings) (email.Message, bool)

BuildWith is Build with the month's off-peak saving estimate (nil: no line).

func (*Job) RunOnce

func (j *Job) RunOnce(ctx context.Context) (Stats, error)

RunOnce considers every opted-in user once. Errors on one user never stop the others.

type Links struct {
	Statement string
	Share     string
	Unpriced  string
	Settings  string
	// Unsubscribe is the signed one-click link (GET shows a page,
	// POST performs it).
	Unsubscribe string
}

Links are absolute URLs on the sender-aligned domain; src=statement is the only parameter added (no tracking pixel, no per-user ids).

type Mailer

type Mailer interface {
	Send(ctx context.Context, m email.Message) error
}

Mailer is the part of the email client the job uses.

type MonthlyFunc

type MonthlyFunc func(ctx context.Context, uid uuid.UUID, period string, loc *time.Location) (ledger.Monthly, error)

MonthlyFunc returns the ledger's own monthly statement (all vehicles) for the user and YYYY-MM period in loc: the same code path as GET /api/ledger/monthly.

type Prefs

type Prefs struct {
	Enabled  bool
	Timezone string // IANA; "UTC" when unset
}

Prefs is one user's opt-in.

type Rendered

type Rendered struct {
	Subject string
	Text    string
	HTML    string
	// Silent is true when the month had no sessions and no drives:
	// nothing is sent.
	Silent bool
}

Rendered is a ready-to-send message.

func Render

func Render(in Input) Rendered

Render builds subject, text and HTML. Totals come straight from the ledger's Monthly: nothing is re-summed except the display split of numbers the ledger already provides.

type SavingsFunc added in v0.38.10

type SavingsFunc func(ctx context.Context, uid uuid.UUID, period string, loc *time.Location) (*ledger.Savings, error)

SavingsFunc returns the user's off-peak saving estimate for the period (nil when it cannot be made). It is only called for a user who is being sent the statement; an error just leaves the line out.

type Signer

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

Signer signs and verifies one-click unsubscribe tokens: base64url(user_id|period|expiry_unix) "." base64url(HMAC-SHA256). The key is derived from the server's session secret ("pepper") with a purpose label, so a session cookie can never be replayed as a token or the reverse.

func NewScopedSigner added in v0.37.2

func NewScopedSigner(secret []byte, scope string) *Signer

NewScopedSigner is a Signer whose key also depends on scope, so a token from one kind of email (the weekly recap) can never verify as another's (the monthly statement), or the reverse.

func NewSigner

func NewSigner(secret []byte) *Signer

NewSigner derives the signing key from secret. A short or empty secret is refused by the caller (main), not here.

func (*Signer) Sign

func (s *Signer) Sign(uid uuid.UUID, period string, exp time.Time) string

Sign returns a token for (uid, period) valid until exp.

func (*Signer) Verify

func (s *Signer) Verify(tok string, now time.Time) (uuid.UUID, string, error)

Verify checks signature and expiry (constant-time compare) and returns the user and period.

type Stats

type Stats struct {
	Considered, Due, Sent, Failed, Skipped, AlreadyDone int
	// StuckSending is claims left 'sending' for over an hour.
	StuckSending int
}

Stats is what one run did.

type Store

type Store interface {
	GetPrefs(ctx context.Context, uid uuid.UUID) (Prefs, error)
	SetPrefs(ctx context.Context, uid uuid.UUID, p Prefs) error
	// LastSentPeriod is the newest period with status 'sent', or "".
	LastSentPeriod(ctx context.Context, uid uuid.UUID) (string, error)
	// Email returns the account's VERIFIED email address ("" = none or
	// unverified): statements are only ever mailed to an address the
	// identity provider verified.
	Email(ctx context.Context, uid uuid.UUID) (string, error)
	// OptedIn lists users with enabled=true and an email address.
	OptedIn(ctx context.Context) ([]Candidate, error)
	// Claim atomically reserves (user, period). It returns true for a
	// new row or a 'failed' row inside RetryWindow; a period that is
	// sending, sent or skipped (or a failed one past the window) is not
	// claimable. This is the idempotency key: it holds across replicas.
	Claim(ctx context.Context, uid uuid.UUID, period string, now time.Time) (bool, error)
	Mark(ctx context.Context, uid uuid.UUID, period, status, reason string, now time.Time) error
	// StuckSending counts claims left in 'sending' for longer than d
	// (a crash between Claim and Mark: at-most-once, never resent, so
	// this is how a silently lost month becomes visible).
	StuckSending(ctx context.Context, d time.Duration, now time.Time) (int, error)
	// Purge deletes send rows created before cutoff.
	Purge(ctx context.Context, cutoff time.Time) (int64, error)
}

Store is the persistence the job and the API need. The Postgres implementation is pgStore; tests use an in-memory fake.

func NewStore

func NewStore(db *sql.DB) Store

NewStore returns the Postgres store.

Jump to

Keyboard shortcuts

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