Documentation
¶
Overview ¶
Package launchnotice is the Terms section 11 notice that Rivolt now has a free tier and Pro (Rivolt Pro, Sprint 14, item 10): its words, the dates it states and the per-account record that it was shown and emailed.
The words are growth's (docs/PRO_LAUNCH_COPY.md, sections 1 and 2), rendered here from three dates so the email and the in-app notice can never disagree. Dates are fixed when an account's row is created (Params): a retried email states the dates the first attempt would have, so the stated notice period is never shorter than 30 days from the email.
Index ¶
- Constants
- Variables
- func FoundingSubject(p FoundingParams) string
- func RenderEmail(p Params) string
- func RenderFoundingEmail(p FoundingParams) string
- func RenderRenewalEmail(p RenewalParams) string
- func RenewalKind(renewsOn time.Time) string
- func RenewalSubject(p RenewalParams) string
- func Unfilled(s string) string
- type DatedJob
- type DatedStats
- type DatedStore
- type FoundingCandidate
- type FoundingParams
- type InApp
- type Link
- type Mailer
- type Notice
- type PGDated
- func (s PGDated) FoundingCandidates(ctx context.Context, now time.Time) ([]FoundingCandidate, error)
- func (s PGDated) MarkSent(ctx context.Context, uid uuid.UUID, kind string) error
- func (s PGDated) PrepareFounding(ctx context.Context, c FoundingCandidate, now time.Time, wantEmail bool) (Prepared, error)
- func (s PGDated) ReleaseClaim(ctx context.Context, uid uuid.UUID, kind string) error
- type Params
- type Prepared
- type Renewal
- type RenewalJob
- type RenewalParams
- type RenewalSource
- type State
- type Store
- func (s Store) ClaimEmail(ctx context.Context, uid uuid.UUID, kind string) (Params, bool, error)
- func (s Store) ClaimEmailAny(ctx context.Context, uid uuid.UUID, kind string) (json.RawMessage, bool, error)
- func (s Store) Dismiss(ctx context.Context, uid uuid.UUID, kind string) (bool, error)
- func (s Store) Ensure(ctx context.Context, uid uuid.UUID, kind string, p Params) (created bool, err error)
- func (s Store) EnsureAny(ctx context.Context, uid uuid.UUID, kind string, params any, ...) (created bool, err error)
- func (s Store) List(ctx context.Context, uid uuid.UUID, now time.Time) ([]Notice, error)
- func (s Store) MarkSent(ctx context.Context, uid uuid.UUID, kind string) error
- func (s Store) ReleaseClaim(ctx context.Context, uid uuid.UUID, kind string) error
- func (s Store) States(ctx context.Context, kind string) (map[uuid.UUID]State, error)
Constants ¶
const ( MinNotice = 30 * 24 * time.Hour SendFrom = 45 * 24 * time.Hour )
Notice windows. The promise (Terms section 5 and the launch email) is "at least 30 days before that date"; the job starts sending at 45 days so an outage of a few days cannot eat the margin.
const ( KindBillingPurchase = "billing_purchase" KindBillingPaymentFailed = "billing_payment_failed" )
The transactional billing emails' metric kinds (internal/billingmail): the purchase confirmation and the payment-failed notice.
const ( OutcomeSent = "sent" OutcomeInApp = "in_app" OutcomeSkipped = "skipped" OutcomeFailed = "failed" OutcomeLate = "late" )
The outcomes the dated jobs report (rivolt_dated_notices_total{kind,outcome}). Observe is only ever called with one of these; the metric pre-creates every (kind, outcome) pair at 0 so the first late notice is an increase, not a series that appears at 1.
const ClaimStale = 15 * time.Minute
ClaimStale is how long a claimed email may sit unsent before another run may claim it again (a crash between claim and send).
const ExportURL = "https://rivolt.dev/settings#export"
ExportURL is the Settings card that exports everything (the full export stays free).
const Kind = "launch"
Kind of the launch notice.
const KindAnnualRenewal = "annual_renewal"
KindAnnualRenewal is the metric kind of the yearly-renewal reminder (its notice rows are per period: see RenewalKind).
const KindFoundingEnd = "founding_end"
KindFoundingEnd is the founding-year-end notice.
const PlanURL = "https://rivolt.dev/settings#plan"
PlanURL is where the reminder sends the account to cancel: the Plan card in Settings, which opens the billing portal after sign-in. A fixed link on purpose: a pre-made billing-portal session URL expires and works as a bearer link to the customer's billing page if the email is forwarded, so the source cannot supply one.
const Subject = "Rivolt Pro, and what it means for your account"
Subject of the email.
Variables ¶
var ( Outcomes = []string{OutcomeSent, OutcomeInApp, OutcomeSkipped, OutcomeFailed, OutcomeLate} MetricKinds = []string{KindFoundingEnd, KindAnnualRenewal, KindBillingPurchase, KindBillingPaymentFailed} )
Outcomes lists them; MetricKinds lists the kinds. Add a new outcome here and the metric and its test follow.
Functions ¶
func FoundingSubject ¶
func FoundingSubject(p FoundingParams) string
FoundingSubject is the subject of the founding-year-end email. A subscribed account's says its subscription continues.
func RenderEmail ¶
RenderEmail is the plain-text email (copy section 1). It is a service notice: no unsubscribe link, the standard footer.
func RenderFoundingEmail ¶
func RenderFoundingEmail(p FoundingParams) string
RenderFoundingEmail is the plain-text founding-year-end email (copy section 4). Paragraphs are single lines, for the mail client to wrap. A subscribed account gets a short note that its subscription continues, with no list of what a free account loses and no offer; a free one gets what changes, what does not, how to export first and how to keep Pro. An extended year says so.
func RenderRenewalEmail ¶
func RenderRenewalEmail(p RenewalParams) string
RenderRenewalEmail is the draft reminder text.
func RenewalKind ¶
RenewalKind is the notice kind of the reminder for the renewal on this date.
func RenewalSubject ¶
func RenewalSubject(p RenewalParams) string
RenewalSubject is the subject of the reminder.
Types ¶
type DatedJob ¶
type DatedJob struct {
Store DatedStore
Mailer Mailer
Now func() time.Time
Logger *slog.Logger
// Skip excludes accounts (the demo and auth-bypass users).
Skip map[uuid.UUID]bool
// OnExtend is told after an account's founding year was extended (invalidate the plan cache).
OnExtend func(uid uuid.UUID)
// Observe counts an outcome ("sent", "failed", "in_app", "skipped", "late").
Observe func(kind, outcome string)
}
DatedJob sends the founding-year-end notices.
func (*DatedJob) RunOnce ¶
func (j *DatedJob) RunOnce(ctx context.Context) (DatedStats, error)
RunOnce gives every account whose founding year ends within 45 days its notice: in-app for all, the email too for a verified address. It is safe on every replica (the claim is a database row) and idempotent (an account is noticed once, and an email retried only after a failure). A year under 30 days from its end is extended to 30 days from now first, so the promise is kept, and counted as late.
type DatedStats ¶
type DatedStats struct {
Considered, InApp, Sent, Failed, Skipped int
Late int // years that had to be extended
WorstShortfall time.Duration // the largest shortfall among them
}
Outcomes of one run, counts only.
type DatedStore ¶
type DatedStore interface {
// FoundingCandidates lists accounts whose founding year ends within SendFrom and that still need a notice (or, for a verified address, an email).
FoundingCandidates(ctx context.Context, now time.Time) ([]FoundingCandidate, error)
// PrepareFounding, in one transaction, extends a year that is under 30 days from ending to now + 30 days, fixes the notice's dates in
// account_notices and (when wantEmail) claims the email. Nothing is changed when the email is wanted but cannot be claimed.
PrepareFounding(ctx context.Context, c FoundingCandidate, now time.Time, wantEmail bool) (Prepared, error)
MarkSent(ctx context.Context, uid uuid.UUID, kind string) error
ReleaseClaim(ctx context.Context, uid uuid.UUID, kind string) error
}
DatedStore is what the job needs from the database, so tests run on a fake.
type FoundingCandidate ¶
type FoundingCandidate struct {
UserID uuid.UUID
Email string
Verified bool
End time.Time // the founding row's current_period_end
Subscribed bool // a paid subscription that outlasts the founding year
NextCharge *time.Time // that subscription's current period end, when it has one
}
FoundingCandidate is one account with a running founding year.
type FoundingParams ¶
type FoundingParams struct {
End string `json:"end"` // the founding year's last day, YYYY-MM-DD
// ExtendedFrom, when set, is the end date before the account's year was extended to give 30 days' notice.
ExtendedFrom string `json:"extended_from,omitempty"`
// Subscribed: a paid subscription outlasts the founding year, so nothing changes for the account on that date.
Subscribed bool `json:"subscribed,omitempty"`
// NextCharge is, for a subscribed account, the subscription's current period end (YYYY-MM-DD): the date of its next charge. Empty when
// the subscription has no end date to state.
NextCharge string `json:"next_charge,omitempty"`
}
FoundingParams are the dates a founding-year-end notice states.
func (FoundingParams) FoundingShowsUntil ¶
func (p FoundingParams) FoundingShowsUntil() (time.Time, error)
FoundingShowsUntil is the end of the day the founding year ends.
type InApp ¶
type InApp struct {
Title string `json:"title"`
Body string `json:"body"`
Links []Link `json:"links"`
}
InApp is the in-app notice: a title, a body and links.
func RenderFoundingInApp ¶
func RenderFoundingInApp(p FoundingParams) InApp
RenderFoundingInApp is the in-app notice for the founding-year end.
func RenderInApp ¶
RenderInApp is the banner on Now and the row in Settings (copy section 2).
type Notice ¶
type Notice struct {
Kind string
// Params are the launch notice's dates (empty for other kinds); Raw is the stored JSON of any kind.
Params Params
Raw json.RawMessage
ShowsUntil time.Time
CreatedAt time.Time
EmailSent *time.Time
Dismissed *time.Time
}
Notice is one account's notice.
type PGDated ¶
PGDated is DatedStore over the system pool.
func (PGDated) FoundingCandidates ¶
func (PGDated) PrepareFounding ¶
type Params ¶
type Params struct {
Launch string `json:"launch"` // LAUNCH_DATE: the hosted service has a free tier and Pro from here
FoundingEnd string `json:"founding_end"` // FOUNDING_END_DATE: launch + 1 year, when the free Pro year ends
TermsEffective string `json:"terms_effective"` // TERMS_EFFECTIVE_EXISTING: launch + 30 days, when the new Terms bind existing accounts
}
Params are the dates a notice states, as YYYY-MM-DD.
type Prepared ¶
type Prepared struct {
Params FoundingParams
// Claimed: the account's email is ours to send (false for an account with no email to send, or one another run holds).
Claimed bool
// Extended: the year was extended to give 30 days' notice; Shortfall is how many days short it would have been.
Extended bool
Shortfall time.Duration
}
Prepared is what a founding-year-end notice states, fixed in the same transaction that claimed it.
type Renewal ¶
type Renewal struct {
UserID uuid.UUID
Email string
Verified bool
RenewsOn time.Time
PriceLabel string // "$34.99 a year"
}
Renewal is one annual subscription about to renew.
type RenewalJob ¶
type RenewalJob struct {
Source RenewalSource
Store Store
Mailer Mailer
Now func() time.Time
Logger *slog.Logger
Skip map[uuid.UUID]bool
// Observe counts an outcome (one of Outcomes) under the kind KindAnnualRenewal.
Observe func(kind, outcome string)
}
RenewalJob sends the reminders.
func (*RenewalJob) RunOnce ¶
func (j *RenewalJob) RunOnce(ctx context.Context) (DatedStats, error)
RunOnce reminds every account whose annual subscription renews within 45 days (and has not been reminded for that renewal). A renewal already inside 30 days is reminded all the same and counted late, stating its true date.
type RenewalParams ¶
RenewalParams is what a reminder states.
type RenewalSource ¶
type RenewalSource interface {
AnnualRenewals(ctx context.Context, from, to time.Time) ([]Renewal, error)
}
RenewalSource lists the annual subscriptions renewing in [from, to].
type Store ¶
Store is the account_notices table. The operator CLI uses the system pool; the API uses the request pool, where row-level security scopes every statement to the signed-in account.
func (Store) ClaimEmail ¶
ClaimEmail takes the right to send the account's email: it succeeds only when the email has not been sent and no other run holds a fresh claim. It returns the params stored on the row, which are the dates the email must state.
func (Store) ClaimEmailAny ¶
func (s Store) ClaimEmailAny(ctx context.Context, uid uuid.UUID, kind string) (json.RawMessage, bool, error)
ClaimEmailAny is ClaimEmail for any kind; it returns the stored params as JSON.
func (Store) Dismiss ¶
Dismiss hides the notice's banner for the account (the Settings row stays). It is idempotent; false means the account has no such notice.
func (Store) Ensure ¶
func (s Store) Ensure(ctx context.Context, uid uuid.UUID, kind string, p Params) (created bool, err error)
Ensure creates the account's in-app notice with the given params if it has none; an existing row (and its dates) is left alone. created says whether this call made it.
func (Store) EnsureAny ¶
func (s Store) EnsureAny(ctx context.Context, uid uuid.UUID, kind string, params any, shownUntil time.Time) (created bool, err error)
EnsureAny is Ensure for any kind: params is whatever JSON the kind states, shownUntil when its in-app notice stops showing.
func (Store) List ¶
List is the account's notices that are still within their window, newest first (the caller's account only, by row-level security). The transactional billing emails (kinds billing_*) are email-only: they have rows here for their claim, and are never listed.
func (Store) MarkSent ¶
MarkSent records that the email went out: the evidence that notice was given.
func (Store) ReleaseClaim ¶
ReleaseClaim gives the claim back after a failed send, so a later run retries it.