stripebill

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: 21 Imported by: 0

Documentation

Overview

Package stripebill is Rivolt's side of Stripe: verifying and applying webhook events (this file and webhook.go) and, next to them, the hosted Checkout and Customer Portal sessions. Card data never touches Rivolt: Stripe's hosted pages take it. Everything here is built and tested against Stripe's documented event shapes and its documented signature scheme, offline; nothing needs the Stripe account.

Index

Constants

View Source
const (
	OutcomeApplied   = "applied"
	OutcomeDuplicate = "duplicate"
	OutcomeIgnored   = "ignored"
	OutcomeUnmapped  = "unmapped"
	OutcomeStale     = "stale"
	OutcomeRejected  = "rejected"
)

Outcomes.

View Source
const BillingAddressCollection = "required"

BillingAddressCollection is what every Checkout session asks Stripe for: the customer's billing address, required. Rivolt Pro is sold in the US only; the address gives the webhook a country to check, and Stripe Tax needs it anyway.

View Source
const DefaultAPIBase = "https://api.stripe.com"

DefaultAPIBase is Stripe's REST API. Tests point Client.BaseURL at a local fake.

View Source
const EventRetention = 30 * 24 * time.Hour

EventRetention is how long event ids are kept: Stripe retries a failed delivery for about three days, so a month is ample.

View Source
const MaxBodyBytes = 1 << 20

MaxBodyBytes caps a webhook body: Stripe events are a few KB.

View Source
const MetadataUserID = "rivolt_user_id"

MetadataUserID is the key Rivolt puts the account's id under in a subscription's metadata (Checkout's subscription_data.metadata, part b), so a subscription event resolves its account on its own, whatever order Stripe delivers the events in.

View Source
const MinTrialLead = 48 * time.Hour

MinTrialLead is Stripe's floor for a Checkout subscription's trial_end: at least 48 hours ahead.

View Source
const SignatureTolerance = 5 * time.Minute

SignatureTolerance is how far a signed timestamp may be from now (Stripe's libraries default to 5 minutes): a captured delivery cannot be replayed later.

Variables

View Source
var (
	ErrBadInterval       = errors.New("stripe: interval must be month or year")
	ErrBillingOff        = errors.New("stripe: billing is not enabled on this server")
	ErrAlreadySubscribed = errors.New("stripe: the account already has a paid subscription")
	ErrNoCustomer        = errors.New("stripe: the account has no Stripe customer")
	// ErrNoPaymentConfig: Checkout is refused when no payment method configuration is set, rather than creating a session that offers
	// whatever the Dashboard's default configuration says (bank debits, buy-now-pay-later and other methods whose refund and dispute flows do
	// not match our 14-day refund copy).
	ErrNoPaymentConfig = errors.New("stripe: RIVOLT_STRIPE_PAYMENT_METHOD_CONFIG is not set to a payment method configuration (pmc_...)")
)

Errors the handlers turn into answers.

View Source
var (
	ErrNoSecret         = errors.New("stripe: webhook secret not configured")
	ErrBadHeader        = errors.New("stripe: malformed Stripe-Signature header")
	ErrNoValidSignature = errors.New("stripe: no valid signature for the payload")
	ErrStaleTimestamp   = errors.New("stripe: signature timestamp outside the tolerance")
)

Errors VerifySignature returns. They are for logs; the response to Stripe is the same for all of them.

View Source
var (
	MetricTypes    = []string{"checkout.session.completed", "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", "charge.refunded", "other", "none"}
	MetricOutcomes = []string{OutcomeApplied, OutcomeDuplicate, OutcomeIgnored, OutcomeUnmapped, OutcomeStale, OutcomeRejected}
)

MetricTypes are the event types the endpoint handles, as the metric labels them; any other type is "other", and a delivery rejected before its type is known is "none". MetricOutcomes are the outcomes. Both are fixed sets, so the label cardinality is bounded by this package.

View Source
var ErrCancelNotQueued = errors.New("stripe: subscriptions could not be cancelled or queued for cancellation")

ErrCancelNotQueued is returned when the cancel failed and could not be queued either: the account must not be deleted.

View Source
var ErrNotConfigured = errors.New("stripe: no secret key configured")

ErrNotConfigured is a client with no secret key.

View Source
var ErrOpenItems = errors.New("stripe: the customer has an open invoice or an unresolved dispute")

ErrOpenItems means a customer still has an open (unpaid) invoice or an unresolved dispute, so it is not deleted yet: Stripe needs the customer to settle those, and deleting it would orphan them. The caller retries later; it never gives up on the deletion.

View Source
var ErrUnmapped = errors.New("stripe: no account for the event yet")

ErrUnmapped is an event for an account Rivolt cannot find (yet). It is answered 500 so Stripe retries for days: Stripe does not order its events, and a subscription event can arrive before the checkout.session.completed that links its customer.

Functions

func ConsentLine

func ConsentLine(p Price, firstCharge, now time.Time) string

ConsentLine is the sentence shown beside the pay button, built from the session's own inputs, so it is true by construction: what will be charged, when the first charge is, and how it renews. firstCharge is the trial's end when the account is mid-trial (Stripe starts billing then), otherwise now.

func EntitlementStatus

func EntitlementStatus(stripeStatus string) (status string, ok bool)

EntitlementStatus maps a Stripe subscription status to an entitlement status. ok is false for a status that must change nothing (incomplete: the first payment has not happened, so there is nothing to grant and nothing to take away).

func FreePeriodLine added in v0.40.17

func FreePeriodLine(p Price, source string, firstCharge time.Time) string

FreePeriodLine explains, on a Checkout that carries trial_end, what Stripe's own page calls a trial ("Try ... free", "Start trial"): the account's Pro is already free until firstCharge (its founding year, or another free period), nothing is charged before then, and the price renews. Shown beside the pay button (custom_text after_submit; Stripe allows 1,200 characters), with the same date as ConsentLine. source is the entitlement that grants the free time.

func MetricType added in v0.40.17

func MetricType(t string) string

MetricType is the label for an event type.

func PurgeEvents

func PurgeEvents(ctx context.Context, d *sql.DB) (int64, error)

PurgeEvents deletes event ids older than the retention (the existing retention job calls it daily).

func Sign

func Sign(body []byte, secret string, at time.Time) string

Sign builds a Stripe-Signature header for a body (tests, and the local fake Stripe).

func VerifySignature

func VerifySignature(body []byte, header, secret string, now time.Time, tolerance time.Duration) error

VerifySignature checks a Stripe-Signature header against the raw request body, as Stripe documents it: the header is "t=<unix>,v1=<hex>[,v1=<hex>...][,v0=...]"; the signed payload is "<t>.<body>"; each v1 is HMAC-SHA256 of it with the endpoint secret, hex-encoded. Any v1 matching passes (several are sent while a secret is being rolled); v0 (the test-mode scheme) is never accepted. The comparison is constant-time, the timestamp must be within tolerance of now, and the body must be the exact bytes received.

Types

type AccountClosure

type AccountClosure struct {
	DB     *sql.DB
	Client *Client // nil: no Stripe on this server; every cancel is queued until one is configured
	Log    *slog.Logger
	// Report is told, after each retry pass, how many cancellations are queued and how long the oldest has waited (gauges for the alert).
	Report func(pending int, oldest time.Duration)
}

AccountClosure is the Stripe step of every account deletion (self-service, admin and the inactive purge): before the account's rows go, its Stripe subscriptions are cancelled at Stripe, so a deleted account is never charged again, and the Stripe customer record (name, email, address) is deleted once nothing is open on it (no unpaid invoice, no unresolved dispute; Stripe keeps the invoices tax law needs). When Stripe cannot be reached (or this server has no Stripe key) the cancel is queued, keyed by the Stripe customer id, and RunRetrier repeats it until it works; it never expires, and a backlog is an alert, never a silent skip. Only when the cancel AND the queueing fail does the deletion stop.

The account's customer is read from billing_customers before the cascade removes it. An account that never bought has no customer, and there is nothing to do.

func (*AccountClosure) BeforeDelete

func (c *AccountClosure) BeforeDelete(ctx context.Context, uid uuid.UUID) error

BeforeDelete cancels uid's Stripe subscriptions, or queues the cancel, and returns an error only when neither worked (the caller must not delete the account then). nil-safe: without an AccountClosure there is nothing to do.

func (*AccountClosure) RetryOnce

func (c *AccountClosure) RetryOnce(ctx context.Context)

RetryOnce runs one pass: retry what is due, purge old tombstones, report the backlog.

func (*AccountClosure) RunRetrier

func (c *AccountClosure) RunRetrier(ctx context.Context, interval time.Duration)

RunRetrier retries queued cancellations every interval until ctx ends. Safe on every replica (rows are claimed with SKIP LOCKED). No-op without a database.

type Charge

type Charge struct {
	ID       string `json:"id"`
	Customer string `json:"customer"`
	Refunded bool   `json:"refunded"`
}

Charge is the part of a Charge (charge.refunded) Rivolt reads. (A dispute object carries no customer, so mapping it to an account needs an API call; disputes are left to the Stripe dashboard until the live account exists.)

type CheckoutParams

type CheckoutParams struct {
	Form        url.Values
	FirstCharge time.Time
	Price       Price
}

CheckoutParams are the inputs of one session, exposed so a test can see exactly what is sent to Stripe.

type CheckoutSession

type CheckoutSession struct {
	Mode              string `json:"mode"`
	Customer          string `json:"customer"`
	Subscription      string `json:"subscription"`
	ClientReferenceID string `json:"client_reference_id"`
	// CustomerDetails.Address.Country is the billing country the customer entered (two letters), when Checkout collected it.
	CustomerDetails struct {
		Address struct {
			Country string `json:"country"`
		} `json:"address"`
	} `json:"customer_details"`
}

CheckoutSession is the part of a completed Checkout Session Rivolt reads.

type Client

type Client struct {
	BaseURL   string // default DefaultAPIBase
	SecretKey string
	HTTP      *http.Client // default: 10 s timeout
	// contains filtered or unexported fields
}

Client is the little of Stripe's REST API Rivolt calls: Checkout Sessions, Customer Portal sessions and Prices. Plain net/http with form-encoded bodies, the way Stripe documents it, so the whole thing is testable against a fake server. The secret key is sent only to BaseURL, as a Bearer token, and is never logged or returned in an error.

func (*Client) CancelCustomerSubscriptions

func (c *Client) CancelCustomerSubscriptions(ctx context.Context, customer string) (int, error)

CancelCustomerSubscriptions cancels, at once and without proration or an invoice, every subscription of a customer that is not already over, and returns how many it cancelled. A customer Stripe does not know (404) has nothing to cancel. Idempotent: a subscription cancelled by an earlier attempt is no longer listed as live. Used when an account is deleted: a deleted account is never charged again.

func (*Client) CheckoutSessionURL

func (c *Client) CheckoutSessionURL(ctx context.Context, form url.Values, idem string) (string, error)

CheckoutSessionURL creates a Checkout Session and returns the hosted page's URL.

func (*Client) CloseCustomer

func (c *Client) CloseCustomer(ctx context.Context, customer string) error

CloseCustomer is everything the deletion of an account asks of Stripe: cancel the subscriptions, then delete the customer record once nothing is open. Idempotent, so a retry repeats it safely.

func (*Client) CustomerCountry added in v0.40.17

func (c *Client) CustomerCountry(ctx context.Context, customer string) (string, error)

CustomerCountry reads the country (two letters) of a customer's address (read-only); "" when it has none.

func (*Client) DeleteCustomerIfClear

func (c *Client) DeleteCustomerIfClear(ctx context.Context, customer string) error

DeleteCustomerIfClear deletes the Stripe customer object (its name, email and address with it) once nothing is open on it: no unpaid invoice and no unresolved dispute (ErrOpenItems otherwise, nothing deleted). Stripe keeps the invoices and charges for its own compliance after a customer is deleted. An unknown customer (404) is already gone. Idempotent.

func (*Client) EnabledCheckoutMethods added in v0.40.17

func (c *Client) EnabledCheckoutMethods(ctx context.Context) (methods []string, found bool, err error)

EnabledCheckoutMethods reads the account's DEFAULT payment method configuration (read-only) and returns the payment method types it offers, sorted. Our Checkout sessions do not use it (they name their own, see PaymentMethodConfiguration), but the Customer Portal's update-payment-method follows it. found is false when there is no default.

func (*Client) PaymentMethodConfiguration added in v0.40.18

func (c *Client) PaymentMethodConfiguration(ctx context.Context, id string) (cfg PaymentMethodConfig, found bool, err error)

PaymentMethodConfiguration reads one payment method configuration (read-only). found is false when Stripe has no such configuration.

func (*Client) PortalFacts added in v0.40.17

func (c *Client) PortalFacts(ctx context.Context) (PortalFacts, error)

PortalFacts reads the default Customer Portal configuration (read-only). A key without the permission gets a *Error.

func (*Client) PortalSessionURL

func (c *Client) PortalSessionURL(ctx context.Context, customer, returnURL string) (string, error)

PortalSessionURL creates a Customer Portal session for a customer and returns its URL.

func (*Client) Price

func (c *Client) Price(ctx context.Context, id string) (Price, error)

Price reads a Price (cached for the life of the process: a price's amount and interval do not change; a new price is a new id).

func (*Client) PriceMode added in v0.40.17

func (c *Client) PriceMode(ctx context.Context, id string) (live, active bool, err error)

PriceMode reads a Price's live-mode and active flags (read-only): which Stripe mode the key and the price are in.

func (*Client) TaxActive added in v0.40.17

func (c *Client) TaxActive(ctx context.Context) (bool, error)

TaxActive reads Stripe Tax's settings status (read-only): "active" means the head office address and default tax behaviour are set up and automatic tax can be used in this mode. Anything else (pending, or no settings) is false.

type Error

type Error struct {
	Status  int
	Code    string
	Message string
}

Error is a refusal from Stripe. Message is Stripe's own text; it is kept off every log line and queue row (see Error) and never shown to the user.

func (*Error) Error

func (e *Error) Error() string

Error is the status and Stripe's error code only. Stripe's free-text message can echo what was sent (an email, a name, a key), so it is not part of the text that goes to logs or into the retry queue; it stays in the Message field for a debugger.

type Event

type Event struct {
	ID      string `json:"id"`
	Type    string `json:"type"`
	Created int64  `json:"created"`
	Data    struct {
		Object json.RawMessage `json:"object"`
	} `json:"data"`
}

Event is the envelope of a webhook delivery (https://docs.stripe.com/api/events/object); Data.Object is the changed object, parsed by type.

type Handler

type Handler struct {
	DB     *sql.DB // the system pool: the webhook has no signed-in user
	Secret string  // the endpoint's signing secret
	// ObserveLag is told how long after Stripe created an event it was received (the event's own created time against this server's clock).
	// OnBilling is told a billing moment an operator may want to see ("payment_failed": a subscription went past due). Counts only.
	ObserveLag func(time.Duration)
	OnBilling  func(kind string)
	// Invalidate drops the account's cached plan on this pod (the others see it within the plan cache's TTL).
	Invalidate func(uuid.UUID)
	Now        func() time.Time
	Log        *slog.Logger
	// Observe, when set, is told each event's outcome: applied, duplicate, ignored (a type or state that changes nothing), unmapped (no
	// account for the object), stale (older than what was applied), or rejected (bad signature). A fixed vocabulary; no user data.
	Observe func(eventType, outcome string)
	// Countries reads a customer's billing country for the US-only check on subscription changes (nil: only Checkout's own country is
	// checked). OnNonUS is told each billing country that is not the US, the code only: for the counter. Neither ever changes what the
	// event does: a non-US account still gets Pro, and the operator is told (see checkCountry).
	Countries interface {
		CustomerCountry(ctx context.Context, customer string) (string, error)
	}
	OnNonUS func(country string)
	// contains filtered or unexported fields
}

Handler is POST /api/billing/stripe/webhook. It verifies the signature, then applies the event in ONE transaction that also records the event id, so a retried or replayed delivery changes nothing, and a delivery that fails halfway is rolled back whole (claim included) and answered 500 so Stripe retries it. Entitlements are written only here (and by the founding grant, the trial and admin grants).

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type PaymentMethodConfig added in v0.40.18

type PaymentMethodConfig struct {
	Active  bool
	Live    bool     // livemode: a test configuration cannot serve a live key, nor the reverse
	Methods []string // the payment method types it has on, sorted
}

PaymentMethodConfig is what the launch pre-flight reads of a payment method configuration.

type Plans

type Plans interface {
	Billing() bool
	State(ctx context.Context, uid uuid.UUID) (entitlements.State, error)
	FreeUntil(ctx context.Context, uid uuid.UUID) (time.Time, bool, error)
}

Plans reads an account's plan (internal/entitlements.Service).

type PortalFacts added in v0.40.17

type PortalFacts struct {
	Found               bool // a default configuration exists
	TermsSet            bool // business_profile.terms_of_service_url
	PrivacySet          bool // business_profile.privacy_policy_url
	PaymentMethodUpdate bool // features.payment_method_update.enabled
}

PortalFacts is what the default Customer Portal configuration says about the things the launch pre-flight checks. Booleans only.

type Price

type Price struct {
	ID         string
	UnitAmount int64  // minor units (cents)
	Currency   string // lower case
	Interval   string // month or year
}

Price is the part of a Stripe Price Rivolt shows in the consent line.

type Service

type Service struct {
	Client  *Client
	DB      *sql.DB
	Plans   Plans
	Prices  map[string]string // interval ("month", "year") -> Stripe price id
	BaseURL string            // the public site, for the return URLs
	// AutomaticTax turns on Stripe Tax for the session. Off until the Stripe account has Tax set up (an owner step): Checkout refuses a
	// session asking for it otherwise.
	AutomaticTax bool
	// PaymentMethodConfig is the Stripe payment method configuration (pmc_...) every Checkout session uses, which limits the session to cards,
	// Apple Pay and Google Pay (RIVOLT_STRIPE_PAYMENT_METHOD_CONFIG; an id, not a secret). Stripe no longer accepts payment_method_types
	// on a Checkout Session: the methods are managed from the Dashboard, and a configuration is how a session is held to a set of them.
	PaymentMethodConfig string
	Now                 func() time.Time
}

Service creates Checkout and Customer Portal sessions. The account is always the signed-in one, passed by the handler from the session: nothing a request carries names an account. Card data never touches Rivolt: Stripe's hosted pages take it.

func (*Service) BuildCheckout

func (s *Service) BuildCheckout(ctx context.Context, uid uuid.UUID, interval string) (CheckoutParams, error)

BuildCheckout builds the Checkout Session for uid and interval. It refuses an account that already has a paid subscription (a second one would charge twice), and starts billing when a running trial ends (never earlier than Stripe's 48 hours).

func (*Service) Checkout

func (s *Service) Checkout(ctx context.Context, uid uuid.UUID, interval string) (string, error)

Checkout creates the session and returns the hosted page's URL.

func (*Service) Portal

func (s *Service) Portal(ctx context.Context, uid uuid.UUID) (string, error)

Portal creates a Customer Portal session for the account's Stripe customer. It does NOT depend on RIVOLT_BILLING: Checkout is how a subscription starts and stays gated, but a subscription that exists keeps renewing whatever the flag says (the webhooks apply), so its holder must be able to cancel it in the same medium. After a rollback to billing off the Plan card is the only cancel path the purchase emails name.

type Subscription

type Subscription struct {
	ID                string `json:"id"`
	Customer          string `json:"customer"`
	Status            string `json:"status"`
	CancelAtPeriodEnd bool   `json:"cancel_at_period_end"`
	CancelAt          int64  `json:"cancel_at"`
	CurrentPeriodEnd  int64  `json:"current_period_end"`
	Items             struct {
		Data []struct {
			CurrentPeriodEnd int64 `json:"current_period_end"`
			Price            struct {
				UnitAmount int64  `json:"unit_amount"`
				Currency   string `json:"currency"`
				Recurring  struct {
					Interval string `json:"interval"`
				} `json:"recurring"`
			} `json:"price"`
		} `json:"data"`
	} `json:"items"`
	Metadata map[string]string `json:"metadata"`
}

Subscription is the part of a Stripe Subscription object Rivolt reads. current_period_end is on the subscription in older API versions and on its items in newer ones; both are read, the subscription's first.

func (Subscription) PeriodEnd

func (s Subscription) PeriodEnd() time.Time

PeriodEnd is the end of the current period (zero: unknown).

func (Subscription) Terms added in v0.40.17

func (s Subscription) Terms() (interval string, amount int64, currency string)

Terms is what the subscription costs and how often it renews, from its first item's price (empty and zero when the event carries none).

func (Subscription) WillCancel

func (s Subscription) WillCancel() bool

WillCancel is a subscription set to stop at the period end, by either of Stripe's two ways of saying so.

Jump to

Keyboard shortcuts

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