launchnotice

package
v0.40.21 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 10 Imported by: 0

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

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

View Source
const (
	KindBillingPurchase      = "billing_purchase"
	KindBillingPaymentFailed = "billing_payment_failed"
)

The transactional billing emails' metric kinds (internal/billingmail): the purchase confirmation and the payment-failed notice.

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

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

View Source
const ExportURL = "https://rivolt.dev/settings#export"

ExportURL is the Settings card that exports everything (the full export stays free).

View Source
const Kind = "launch"

Kind of the launch notice.

View Source
const KindAnnualRenewal = "annual_renewal"

KindAnnualRenewal is the metric kind of the yearly-renewal reminder (its notice rows are per period: see RenewalKind).

View Source
const KindFoundingEnd = "founding_end"

KindFoundingEnd is the founding-year-end notice.

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

View Source
const Subject = "Rivolt Pro, and what it means for your account"

Subject of the email.

Variables

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

func RenderEmail(p Params) string

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

func RenewalKind(renewsOn time.Time) string

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.

func Unfilled

func Unfilled(s string) string

Unfilled reports a placeholder left in rendered text.

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

func RenderInApp(p Params) InApp

RenderInApp is the banner on Now and the row in Settings (copy section 2).

type Link struct {
	Label string `json:"label"`
	Href  string `json:"href"`
}

Link is a button of the in-app notice.

type Mailer

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

Mailer sends one message.

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

type PGDated struct{ DB *sql.DB }

PGDated is DatedStore over the system pool.

func (PGDated) FoundingCandidates

func (s PGDated) FoundingCandidates(ctx context.Context, now time.Time) ([]FoundingCandidate, error)

func (PGDated) MarkSent

func (s PGDated) MarkSent(ctx context.Context, uid uuid.UUID, kind string) error

func (PGDated) PrepareFounding

func (s PGDated) PrepareFounding(ctx context.Context, c FoundingCandidate, now time.Time, wantEmail bool) (Prepared, error)

func (PGDated) ReleaseClaim

func (s PGDated) ReleaseClaim(ctx context.Context, uid uuid.UUID, kind string) error

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.

func NewParams

func NewParams(launch time.Time) Params

NewParams derives the three dates from the launch date, so they cannot disagree.

func (Params) Check

func (p Params) Check() error

Check refuses params that would state nonsense: a malformed date, or a Terms date fewer than 30 days after the launch date.

func (Params) ShowsUntil

func (p Params) ShowsUntil() (time.Time, error)

ShowsUntil is the end of the day the new Terms take effect: the in-app notice is shown until then.

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

type RenewalParams struct {
	RenewsOn   string `json:"renews_on"`
	PriceLabel string `json:"price"`
}

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 State

type State struct {
	Exists    bool
	EmailSent bool
	Params    Params
	CreatedAt time.Time
}

State is what the operator's dry run needs about one account's row.

type Store

type Store struct{ DB *sql.DB }

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

func (s Store) ClaimEmail(ctx context.Context, uid uuid.UUID, kind string) (Params, bool, error)

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

func (s Store) Dismiss(ctx context.Context, uid uuid.UUID, kind string) (bool, error)

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

func (s Store) List(ctx context.Context, uid uuid.UUID, now time.Time) ([]Notice, error)

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

func (s Store) MarkSent(ctx context.Context, uid uuid.UUID, kind string) error

MarkSent records that the email went out: the evidence that notice was given.

func (Store) ReleaseClaim

func (s Store) ReleaseClaim(ctx context.Context, uid uuid.UUID, kind string) error

ReleaseClaim gives the claim back after a failed send, so a later run retries it.

func (Store) States

func (s Store) States(ctx context.Context, kind string) (map[uuid.UUID]State, error)

States returns the state of every account's row of this kind (operator side: the system pool).

Jump to

Keyboard shortcuts

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