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
- Variables
- func Due(now time.Time, tz string) (period string, loc *time.Location, due bool)
- func Money(v float64, currency string) string
- func MonthName(period string) string
- func PreviousMonth(t time.Time, loc *time.Location) string
- func Silent(m ledger.Monthly) bool
- type Candidate
- type Input
- type Job
- type Links
- type Mailer
- type MonthlyFunc
- type Prefs
- type Rendered
- type SavingsFunc
- type Signer
- type Stats
- type Store
Constants ¶
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.)
const SendRetention = 13 * 31 * 24 * time.Hour
SendRetention is how long statement_email_sends rows are kept.
const TokenTTL = 90 * 24 * time.Hour
TokenTTL is how long an unsubscribe link in an email works.
Variables ¶
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 ¶
Due reports whether a user in tz is due at now, and the period (the previous full local month) to send.
func PreviousMonth ¶
Period returns the YYYY-MM of the month before the one containing t, in loc.
Types ¶
type Candidate ¶
type Candidate struct {
UserID uuid.UUID
Timezone string
Email string
FirstName string
// HasApp: the account has a live iPhone-app session, so the app link is left out.
HasApp bool
// Note is what the figures cover, when it is not the whole account (a free account's primary vehicle); the Job fills it.
Note 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
// Note, when set, is a line under the heading saying what the figures cover ("Showing R1T. Pro includes all vehicles." for a free account).
Note string
}
Input is everything the template needs.
type Job ¶
type Job struct {
Store Store
Mailer Mailer
Monthly MonthlyFunc
Savings SavingsFunc // optional
// NoteFor, optional, says what a user's figures cover when it is not the whole account; Monthly and Savings must narrow the same way.
NoteFor func(ctx context.Context, uid uuid.UUID) string
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.
type Links ¶
type Links struct {
Statement string
Unpriced string
Settings string
// Unsubscribe is the signed one-click link (GET shows a page,
// POST performs it).
Unsubscribe string
// IOSApp is the iPhone app link (RIVOLT_IOS_APP_URL); empty leaves the line out.
IOSApp 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 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 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.
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
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 ¶
NewSigner derives the signing key from secret. A short or empty secret is refused by the caller (main), not here.
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.