superscribe

package module
v3.0.0-alpha0 Latest Latest
Warning

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

Go to latest
Published: Mar 2, 2020 License: MIT Imports: 8 Imported by: 0

README

Superscribe

Build Status GoDoc

An easier way to handle App Store subscriptions

Overview

Getting App Store Status Update Notifications (now Server-to-Server Notifications) for in-app subscriptions can be tricky. RENEWAL notifications may not occur as expected, CANCEL does not indicate when auto_renew_status was switched on or off, and it can seem arbitrary when you get older iOS 6 style of receipts or the new style.

Others have described these challenges too:

Superscribe intends to provide a basic “just works”, correct solution for the main subscription use cases.

Get started

Install
go get github.com/carpenterscode/superscribe
Configure

Superscribe provides a server that both

  • scans for expiring subscriptions to check for state changes (like successful renewals) and
  • listens for App Store notifications for a limited number of subscription events.

The server needs

  1. Your App Store shared secret
  2. A func you define to retrieve expiring subscriptions from the database, called during a scan operation
  3. A listener to update the database after the scan

See how to connect listeners to Superscribe in example/main.go. This shows how to use the included AppsFlyer listener that attributes events using server-to-server API.

Optionally you can provide

  • More listeners, such as for server-side conversion analytics, etc.
srv.AddListener(AnalyticsListener{db, tracker})
  • HTTP server request handlers, such as for 200 OK responses to /healthz pings
srv.HandleFunc("/healthz", func(writer http.ResponseWriter, req *http.Request) {
	writer.Write([]byte("OK"))
})

You cannot currently

  • Modify the App Store Status Update Notification endpoint. It's currently hardcoded to /superscribe, but we can change that in the future.
  • Use anything more sophisticated than a Go time.Ticker.
Usage
Run automated tests

Generate mocks first

go generate

Test

go test ./... ./receipt

Caveats

Currently, Superscribe should only be run in a single instance setup. I personally run it on production in a single-pod Kubernetes deployment, but we should figure out how to solve for redundancy and performance by adding some kind of scalability.

Future work

There's a lot of unfortunate complexity to subscription management, so the longer term goal is to increase extensibility and robustness.

Most important: Let’s gather real use-cases and requirements to draft a prioritized roadmap.

  • Distinguish among first, first year's worth of, and remaining payments. The paid at event could be made more versatile and track 30% vs 15% App Store fee. Or to filter out renewals from first payments.
  • Track plan upgrade responses from customers. For instance, moving all monthly subscriptions from 7.99/mo to 9.99/mo.
  • Offer a scalable solution. Subscriptions in the local database should only be scanned by a single process, but multiple instances of listeners should be able to coexist. The current 1:1 model limits Superscribe to one instance.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func NewServer

func NewServer(addr, secret string, matcher ExpiringSubscriptions,
	fetch SubscriptionFetch, updater SubscriptionUpdater, interval time.Duration) *server

Types

type AutoRenewEvent

type AutoRenewEvent interface {
	Subscription
	AutoRenewProduct() string
	AutoRenewChangedAt() time.Time
}

type Env

type Env string
const (
	Sandbox Env = "Sandbox"
	Prod    Env = "PROD"
)

type Event

type Event struct {
	// contains filtered or unexported fields
}

func (Event) AdvertisingID

func (evt Event) AdvertisingID() string

func (Event) AutoRenewChangedAt

func (evt Event) AutoRenewChangedAt() time.Time

func (Event) AutoRenewProduct

func (evt Event) AutoRenewProduct() string

func (Event) AutoRenewStatus

func (evt Event) AutoRenewStatus() bool

func (Event) CancelledAt

func (evt Event) CancelledAt() time.Time

func (Event) Currency

func (evt Event) Currency() string

func (Event) DeviceIP

func (evt Event) DeviceIP() string

func (Event) Email

func (evt Event) Email() string

func (Event) ExpiresAt

func (evt Event) ExpiresAt() time.Time

func (Event) FacebookID

func (evt Event) FacebookID() string

func (Event) FirstName

func (evt Event) FirstName() string

func (Event) GetString

func (evt Event) GetString(key string) string

func (Event) GracePeriodEndsAt

func (evt Event) GracePeriodEndsAt() (time.Time, bool)

func (Event) ImageURL

func (evt Event) ImageURL() string

func (Event) IsTrialPeriod

func (evt Event) IsTrialPeriod() bool

func (Event) LastName

func (evt Event) LastName() string

func (Event) OriginalTransactionID

func (evt Event) OriginalTransactionID() string

func (Event) PaidAt

func (evt Event) PaidAt() time.Time

func (Event) PremiumAccess

func (evt Event) PremiumAccess() bool

func (Event) Price

func (evt Event) Price() float64

func (Event) ProductID

func (evt Event) ProductID() string

func (Event) RefundedAt

func (evt Event) RefundedAt() time.Time

func (*Event) SetNote

func (evt *Event) SetNote(note Note)

func (*Event) SetReceiptInfo

func (evt *Event) SetReceiptInfo(resp receipt.Info)

func (*Event) SetRevenue

func (evt *Event) SetRevenue(currency string, price float64)

func (*Event) SetStartedTrialAt

func (evt *Event) SetStartedTrialAt(startedTrialAt time.Time)

func (*Event) SetUser

func (evt *Event) SetUser(user User)

func (Event) SignedUpAt

func (evt Event) SignedUpAt() time.Time

func (Event) StartedTrialAt

func (evt Event) StartedTrialAt() time.Time

func (Event) Status

func (evt Event) Status() int

func (Event) String

func (evt Event) String() string

func (Event) UserID

func (evt Event) UserID() string

type EventListener

type EventListener interface {

	// Name describes the listener for identification in the logs
	Name() string

	// ChangedAutoRenewProduct indicates the next renewal period's product ID
	ChangedAutoRenewProduct(AutoRenewEvent) error

	// ChangedAutoRenewStatus indicates new on/off state
	ChangedAutoRenewStatus(AutoRenewEvent) error

	// Paid indicates a successful charge
	Paid(PayEvent) error

	// Refunded indicates App Store customer support issued a subscription refund of some sort
	Refunded(RefundEvent) error

	// StartedTrial indicates a subscription free trial began
	StartedTrial(StartTrialEvent) error
}

type ExpiringSubscriptions

type ExpiringSubscriptions func(time.Time) []string

ExpiringSubscriptions returns a list of App Store receipts for subscriptions nearing expiration for a specified current time.

type MultiEventListener

type MultiEventListener struct {
	// contains filtered or unexported fields
}

func NewMultiEventListener

func NewMultiEventListener() *MultiEventListener

func (*MultiEventListener) Add

func (multi *MultiEventListener) Add(l EventListener)

func (MultiEventListener) ChangedAutoRenewProduct

func (multi MultiEventListener) ChangedAutoRenewProduct(evt AutoRenewEvent) error

func (MultiEventListener) ChangedAutoRenewStatus

func (multi MultiEventListener) ChangedAutoRenewStatus(evt AutoRenewEvent) error

func (*MultiEventListener) Name

func (multi *MultiEventListener) Name() string

func (MultiEventListener) Paid

func (multi MultiEventListener) Paid(evt PayEvent) error

func (MultiEventListener) Refunded

func (multi MultiEventListener) Refunded(evt RefundEvent) error

func (MultiEventListener) StartedTrial

func (multi MultiEventListener) StartedTrial(evt StartTrialEvent) error

type Note

type Note interface {
	Type() NoteType
	Environment() Env

	receipt.Info

	AutoRenewProduct() string
	AutoRenewChangedAt() time.Time
	RefundedAt() time.Time
	StartedTrialAt() time.Time

	GracePeriodEndsAt() (time.Time, bool)
}

type NoteType

type NoteType string
const (
	Cancel               NoteType = "CANCEL"
	DidChangeRenewalPref NoteType = "DID_CHANGE_RENEWAL_PREF"
	InitialBuy           NoteType = "INITIAL_BUY"
	InteractiveRenewal   NoteType = "INTERACTIVE_RENEWAL"
	Renewal              NoteType = "RENEWAL"

	// Introduced in June 2019 at WWDC
	DidChangeRenewalStatus NoteType = "DID_CHANGE_RENEWAL_STATUS"
	DidFailToRenew         NoteType = "DID_FAIL_TO_RENEW"
)

type Notification

type Notification struct {
	Env              Env      `json:"environment"`
	NotificationType NoteType `json:"notification_type"`
	Password         string   `json:"password"`

	CancellationDate   *receipt.Millistamp `json:"cancellation_date_ms,string,omitempty"`
	WebOrderLineItemID string              `json:"web_order_line_item_id"`

	LatestReceipt            string       `json:"latest_receipt,omitempty"`
	LatestReceiptInfo        receiptInfo  `json:"latest_receipt_info,omitempty"`
	LatestExpiredReceipt     string       `json:"latest_expired_receipt,omitempty"`
	LatestExpiredReceiptInfo *receiptInfo `json:"latest_expired_receipt_info,omitempty"`

	AutoRenewStatus          bool               `json:"auto_renew_status,string"`
	AutoRenewStatusChangedAt receipt.Millistamp `json:"auto_renew_status_change_date_ms,string,omitempty"`
	AutoRenewAdamID          string             `json:"auto_renew_adam_id"`
	AutoRenewProductID       string             `json:"auto_renew_product_id"`
	ExpirationIntent         string             `json:"expiration_intent"`

	UnifiedReceipt receipt.Unified `json:"unified_receipt"`
}

type PayEvent

type PayEvent interface {
	Subscription
	PaidAt() time.Time
}

type RefundEvent

type RefundEvent interface {
	Subscription
	RefundedAt() time.Time
}

type StartTrialEvent

type StartTrialEvent interface {
	Subscription
	StartedTrialAt() time.Time
}

type Subscription

type Subscription interface {
	OriginalTransactionID() string
	ProductID() string

	AutoRenewStatus() bool
	IsTrialPeriod() bool
	ExpiresAt() time.Time
	GracePeriodEndsAt() (time.Time, bool)

	Currency() string
	Price() float64

	User
}

type SubscriptionFetch

type SubscriptionFetch func(string) (Subscription, error)

SubscriptionFetch returns the last known state of a subscription by original transaction ID, which can determine what changes have happened when compared to the latest receipt info.

type SubscriptionUpdater

type SubscriptionUpdater interface {
	UpdateWithNotification(Note) error
	UpdateWithReceipt(receipt.Info) error
}

type User

type User interface {
	UserID() string
	FacebookID() string
	SignedUpAt() time.Time

	FirstName() string
	LastName() string
	Email() string
	ImageURL() string

	AdvertisingID() string
	DeviceIP() string
	PremiumAccess() bool
	GetString(string) string
}

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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