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
- Variables
- func ConsentLine(p Price, firstCharge, now time.Time) string
- func EntitlementStatus(stripeStatus string) (status string, ok bool)
- func FreePeriodLine(p Price, source string, firstCharge time.Time) string
- func MetricType(t string) string
- func PurgeEvents(ctx context.Context, d *sql.DB) (int64, error)
- func Sign(body []byte, secret string, at time.Time) string
- func VerifySignature(body []byte, header, secret string, now time.Time, tolerance time.Duration) error
- type AccountClosure
- type Charge
- type CheckoutParams
- type CheckoutSession
- type Client
- func (c *Client) CancelCustomerSubscriptions(ctx context.Context, customer string) (int, error)
- func (c *Client) CheckoutSessionURL(ctx context.Context, form url.Values, idem string) (string, error)
- func (c *Client) CloseCustomer(ctx context.Context, customer string) error
- func (c *Client) CustomerCountry(ctx context.Context, customer string) (string, error)
- func (c *Client) DeleteCustomerIfClear(ctx context.Context, customer string) error
- func (c *Client) EnabledCheckoutMethods(ctx context.Context) (methods []string, found bool, err error)
- func (c *Client) PaymentMethodConfiguration(ctx context.Context, id string) (cfg PaymentMethodConfig, found bool, err error)
- func (c *Client) PortalFacts(ctx context.Context) (PortalFacts, error)
- func (c *Client) PortalSessionURL(ctx context.Context, customer, returnURL string) (string, error)
- func (c *Client) Price(ctx context.Context, id string) (Price, error)
- func (c *Client) PriceMode(ctx context.Context, id string) (live, active bool, err error)
- func (c *Client) TaxActive(ctx context.Context) (bool, error)
- type Error
- type Event
- type Handler
- type PaymentMethodConfig
- type Plans
- type PortalFacts
- type Price
- type Service
- type Subscription
Constants ¶
const ( OutcomeApplied = "applied" OutcomeDuplicate = "duplicate" OutcomeIgnored = "ignored" OutcomeUnmapped = "unmapped" OutcomeStale = "stale" OutcomeRejected = "rejected" )
Outcomes.
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.
const DefaultAPIBase = "https://api.stripe.com"
DefaultAPIBase is Stripe's REST API. Tests point Client.BaseURL at a local fake.
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.
const MaxBodyBytes = 1 << 20
MaxBodyBytes caps a webhook body: Stripe events are a few KB.
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.
const MinTrialLead = 48 * time.Hour
MinTrialLead is Stripe's floor for a Checkout subscription's trial_end: at least 48 hours ahead.
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 ¶
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.
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.
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.
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.
var ErrNotConfigured = errors.New("stripe: no secret key configured")
ErrNotConfigured is a client with no secret key.
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.
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 ¶
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 ¶
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
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
MetricType is the label for an event type.
func PurgeEvents ¶
PurgeEvents deletes event ids older than the retention (the existing retention job calls it daily).
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 ¶
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 ¶
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 ¶
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 ¶
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
CustomerCountry reads the country (two letters) of a customer's address (read-only); "" when it has none.
func (*Client) DeleteCustomerIfClear ¶
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 ¶
PortalSessionURL creates a Customer Portal session for a customer and returns its URL.
func (*Client) Price ¶
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
PriceMode reads a Price's live-mode and active flags (read-only): which Stripe mode the key and the price are in.
type Error ¶
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.
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).
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) Portal ¶
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.