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 ¶
const ( EnvProduction = "Production" EnvSandbox = "Sandbox" )
Environments an App Store deployment can accept: exactly one per deployment.
const ( OutcomeApplied = "applied" OutcomeDuplicate = "duplicate" OutcomeIgnored = "ignored" OutcomeUnmapped = "unmapped" OutcomeStale = "stale" OutcomeRejected = "rejected" )
Outcomes of an event, the metric's vocabulary (no user data).
const AppleRootG3Fingerprint = "63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179"
AppleRootG3Fingerprint is the published SHA-256 of the root, lower-case hex.
const EventRetention = 30 * 24 * time.Hour
EventRetention is how long notification ids are kept. Apple retries a failed delivery for days, not weeks.
const MaxBodyBytes = 1 << 20
MaxBodyBytes caps a notification body: Apple's are a few KB.
const MaxTransactionBytes = 16 << 10
MaxTransactionBytes caps the app's report: one signed transaction is under 4 KB.
Variables ¶
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.
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.
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.
var Outcomes = []string{OutcomeApplied, OutcomeDuplicate, OutcomeIgnored, OutcomeUnmapped, OutcomeStale, OutcomeRejected}
Outcomes lists them; the metric pre-creates each at 0.
Functions ¶
func PurgeEvents ¶
PurgeEvents deletes notification ids older than the retention (the retention job calls it daily).
func RootFingerprint ¶
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 ¶
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.
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.
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 NewVerifierWithRoot ¶
func NewVerifierWithRoot(root *x509.Certificate) *Verifier
NewVerifierWithRoot pins another root (tests build their own chain with Apple's marker extensions).