Help us shape the future of Go! Take the 2026 Go Developer Survey

applebill

package
v0.40.22 Latest Latest
Warning

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

Go to latest
Published: Nov 2, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package applebill is the server side of StoreKit 2 (Rivolt Pro, Sprint 14, item 5): it verifies what Apple and the app send about a subscription and turns it into the account's `apple` entitlement row. Nothing here trusts a claim before a signature has been checked against Apple's pinned root, and nothing here calls Apple: it is inert unless configured (config.go).

Index

Constants

View Source
const (
	EnvProduction = "Production"
	EnvSandbox    = "Sandbox"
)

Environments an App Store deployment can accept: exactly one per deployment.

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

Outcomes of an event, the metric's vocabulary (no user data).

View Source
const AppleRootG3Fingerprint = "63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179"

AppleRootG3Fingerprint is the published SHA-256 of the root, lower-case hex.

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

EventRetention is how long notification ids are kept. Apple retries a failed delivery for days, not weeks.

View Source
const MaxBodyBytes = 1 << 20

MaxBodyBytes caps a notification body: Apple's are a few KB.

View Source
const MaxTransactionBytes = 16 << 10

MaxTransactionBytes caps the app's report: one signed transaction is under 4 KB.

Variables

View Source
var (
	ErrMalformed = errors.New("applebill: not a compact JWS")
	ErrAlg       = errors.New("applebill: the JWS algorithm is not ES256")
	ErrChain     = errors.New("applebill: the certificate chain is not Apple's")
	ErrSignature = errors.New("applebill: bad signature")
)

Errors, so callers and tests can tell a rejection from a malformed value.

View Source
var ErrRejected = errors.New("applebill: not for this deployment")

ErrRejected is a signed value that is genuine but is not for this deployment: another app, another environment, another product.

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

ErrUnmapped is a notification for a subscription no account has linked yet. It is answered 500, so Apple retries: the app's own report of the transaction (transaction.go) may simply not have arrived yet.

Outcomes lists them; the metric pre-creates each at 0.

Functions

func PurgeEvents

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

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

func RootFingerprint

func RootFingerprint() (string, error)

RootFingerprint is the SHA-256 of the embedded root, lower-case hex.

Types

type Config

type Config struct {
	BundleID    string
	Products    map[string]bool // the product ids Rivolt Pro is sold as
	Environment string
}

Config is the StoreKit configuration of this deployment. nil from ConfigFromEnv means StoreKit is not configured: no route exists.

func ConfigFromEnv

func ConfigFromEnv(getenv func(string) string, billingOn bool) (*Config, error)

ConfigFromEnv reads RIVOLT_APPLE_IAP_BUNDLE_ID, RIVOLT_APPLE_IAP_PRODUCTS (a comma list, REQUIRED when billing is on, so "any product under the bundle" cannot happen on prod by omission) and RIVOLT_APPLE_IAP_ENV (Production, the default, or Sandbox: one per deployment, never both). Without a bundle id it returns (nil, nil). A half-configured deployment is an error the caller must stop on.

func (*Config) Check

func (c *Config) Check(bundle, env, product string) error

Check refuses a bundle, environment or product that is not this deployment's. An empty product (a notification with no transaction) is not checked here; product is checked only when a transaction is present.

type Decision

type Decision struct {
	// Change: write the row (false: the notification changes nothing).
	Change bool
	Status string // entitlements.StatusActive / StatusPastDue / StatusEnded
	// CancelAtPeriodEnd, when non-nil, sets the flag (auto-renew turned off or on); nil leaves it.
	CancelAtPeriodEnd *bool
	// Revoke: a refund or revoke ended Pro for good: older events may not bring it back. Unrevoke lifts that (REFUND_REVERSED).
	Revoke, Unrevoke bool
}

Decision is what a notification does to the account's apple entitlement.

func Decide

func Decide(typ, subtype string, ti TransactionInfo, now time.Time) Decision

Decide maps a notification (type, subtype) and its transaction to a Decision, at now. It is pure.

SUBSCRIBED, DID_RENEW, OFFER_REDEEMED, RENEWAL_EXTENDED   active until the transaction's expiry (ended if that has passed)
DID_CHANGE_RENEWAL_STATUS                                 the cancel-at-period-end flag follows the auto-renew subtype
DID_FAIL_TO_RENEW                                         past_due in a grace period; otherwise Pro runs to its expiry
GRACE_PERIOD_EXPIRED, EXPIRED                             ended
REFUND, REVOKE                                            ended at once, and nothing older may revive it
REFUND_REVERSED                                           lifts the revoke: active until the expiry
anything else (TEST, CONSUMPTION_REQUEST, PRICE_INCREASE, REFUND_DECLINED, ...)   changes nothing

type Handler

type Handler struct {
	DB       *sql.DB // the system pool: Apple has no signed-in user
	Verifier *Verifier
	Config   *Config
	// Invalidate drops the account's cached plan on this pod (the others see it within the plan cache's TTL). Called on EVERY change.
	Invalidate func(uuid.UUID)
	Now        func() time.Time
	Log        *slog.Logger
	// Observe is told each notification's outcome (Outcomes); a fixed vocabulary, no user data.
	Observe func(outcome string)
}

Handler is POST /api/billing/apple/notifications. It verifies the signed payload and the transaction in it, then applies the notification in ONE transaction that also records its UUID, so a replay changes nothing and a delivery that fails halfway rolls back whole (claim included) and is answered 500 so Apple retries it. Entitlements are written only here, by the client report, the Stripe webhook, 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 NotificationBody

type NotificationBody struct {
	SignedPayload string `json:"signedPayload"`
}

NotificationBody is what Apple POSTs: one signed value.

type NotificationData

type NotificationData struct {
	Environment           string `json:"environment"`
	BundleID              string `json:"bundleId"`
	SignedTransactionInfo string `json:"signedTransactionInfo"`
	SignedRenewalInfo     string `json:"signedRenewalInfo"`
}

NotificationData is a payload's data object (absent on a TEST notification).

type NotificationPayload

type NotificationPayload struct {
	NotificationType string            `json:"notificationType"`
	Subtype          string            `json:"subtype"`
	NotificationUUID string            `json:"notificationUUID"`
	Version          string            `json:"version"`
	SignedDate       int64             `json:"signedDate"` // ms since the epoch
	Data             *NotificationData `json:"data"`
}

NotificationPayload is the decoded signedPayload of an App Store Server Notification V2.

type Transaction

type Transaction struct {
	DB         *sql.DB // the system pool: it writes the link and the entitlement table
	Verifier   *Verifier
	Config     *Config
	Invalidate func(uuid.UUID)
	Now        func() time.Time
	Log        *slog.Logger
}

Transaction is POST /api/billing/apple/transaction: the signed-in account reports the transaction its purchase produced. The app's claim is worth nothing until the signed value verifies against Apple's root; then the transaction must be for this deployment's bundle, environment and product, and it must carry appAccountToken equal to the caller's account id. A signed transaction is a blob anyone can copy, so without that check someone handed another person's transaction could post it first and link that subscription to their own account. The app always sets the token when it buys.

func (*Transaction) Serve

func (t *Transaction) Serve(uid uuid.UUID, w http.ResponseWriter, r *http.Request)

Serve handles the request for the signed-in uid.

func (*Transaction) ServeHTTP

func (t *Transaction) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP adapts Serve to a handler that reads the signed-in user from the request.

type TransactionInfo

type TransactionInfo struct {
	OriginalTransactionID string `json:"originalTransactionId"`
	TransactionID         string `json:"transactionId"`
	BundleID              string `json:"bundleId"`
	ProductID             string `json:"productId"`
	Type                  string `json:"type"`
	Environment           string `json:"environment"`
	AppAccountToken       string `json:"appAccountToken"`
	PurchaseDate          int64  `json:"purchaseDate"`
	ExpiresDate           int64  `json:"expiresDate"`
	RevocationDate        int64  `json:"revocationDate"`
	RevocationReason      int    `json:"revocationReason"`
	SignedDate            int64  `json:"signedDate"`
}

TransactionInfo is a decoded signed transaction (JWSTransactionDecodedPayload), the fields Rivolt reads.

func (TransactionInfo) Expires

func (t TransactionInfo) Expires() time.Time

Expires is the end of the paid period (zero: none).

func (TransactionInfo) Revoked

func (t TransactionInfo) Revoked() bool

Revoked is when Apple revoked the transaction (a refund, or a family-sharing loss); zero when it has not.

func (TransactionInfo) Subscription

func (t TransactionInfo) Subscription() bool

Subscription is whether this transaction is an auto-renewable subscription, the only kind Rivolt Pro sells.

type Verifier

type Verifier struct {

	// Now is the time certificates are checked at (nil: time.Now).
	Now func() time.Time
	// contains filtered or unexported fields
}

Verifier checks Apple-signed JWS values.

func NewVerifier

func NewVerifier() (*Verifier, error)

NewVerifier pins Apple's root.

func NewVerifierWithRoot

func NewVerifierWithRoot(root *x509.Certificate) *Verifier

NewVerifierWithRoot pins another root (tests build their own chain with Apple's marker extensions).

func (*Verifier) Verify

func (v *Verifier) Verify(compact string, out any) error

Verify checks a compact JWS and unmarshals its payload into out. Order matters: the algorithm and the chain are checked, then the signature, and only then is the payload parsed.

Jump to

Keyboard shortcuts

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