Export all subscriptionStore methods.
We're not going to wrap all our subscriptionStore interactions in a Context
type, so we need to expose all the functions so other packages can call them.
Also, it's now SubscriptionStore.
Also creates a SubscriptionRequest type that is used to create a new
Subscription instance, along with a Validate method on it, to detect errors when
creating a SubscriptionRequest.
Update our CreateSubscription function to be New, and have it take a
SubscriptionRequest, a Stripe instance, and a SubscriptionStore as arguments.
It'll create the Subscription in the SubscriptionStore, create a customer in
Stripe, and create the subscription in Stripe, then associate the Stripe
subscription with the created Subscription.
Also, fix our StripeSubscriptionChange function to correctly equate a
missing/zero Unix timestamp from Stripe with a missing/zero time.Time.
7 "code.secondbit.org/api.hg"
8 "code.secondbit.org/uuid.hg"
12 // ErrSubscriptionAlreadyExists is returned when a Subscription
13 // with an identical ID already exists in the subscriptionStore.
14 ErrSubscriptionAlreadyExists = errors.New("Subscription already exists")
15 // ErrSubscriptionNotFound is returned when a single Subscription
16 // is acted upon or requested, but cannot be found.
17 ErrSubscriptionNotFound = errors.New("Subscription not found")
18 // ErrStripeSubscriptionAlreadyExists is returned when a Subscription
19 // is created or updates its StripeSubscription property, but that
20 // StripeSubscription is already associated with another Subscription.
21 ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription")
22 // ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
23 // is empty but is passed to subscriptionStore.UpdateSubscription
25 ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty")
26 // ErrNoSubscriptionID is returned when one or more Subscription IDs
27 // are required, but none are provided.
28 ErrNoSubscriptionID = errors.New("no Subscription ID provided")
30 planOptions = []string{"basic_monthly", "basic_yearly", "supporter_monthly", "supporter_yearly"}
35 // Subscription represents the state of a user's payments. It holds
36 // metadata about the last time a user was charged, how much a user
37 // should be charged, how to charge a user and how much to charge
39 type Subscription struct {
40 UserID uuid.ID `json:"user_id"`
41 StripeSubscription string `json:"stripe_subscription"`
42 Plan string `json:"plan"`
43 Status string `json:"status"`
44 Canceling bool `json:"canceling"`
45 Created time.Time `json:"created"`
46 TrialStart time.Time `json:"trial_start,omitempty"`
47 TrialEnd time.Time `json:"trial_end,omitempty"`
48 PeriodStart time.Time `json:"period_start,omitempty"`
49 PeriodEnd time.Time `json:"period_end,omitempty"`
50 CanceledAt time.Time `json:"canceled_at,omitempty"`
51 FailedChargeAttempts int `json:"failed_charge_attempts"`
52 LastFailedCharge time.Time `json:"last_failed_charge,omitempty"`
53 LastNotified time.Time `json:"last_notified,omitempty"`
56 // SubscriptionChange represents desired changes to a Subscription
57 // object. A nil value means that property should remain unchanged.
58 type SubscriptionChange struct {
59 StripeSubscription *string
65 PeriodStart *time.Time
68 FailedChargeAttempts *int
69 LastFailedCharge *time.Time
70 LastNotified *time.Time
73 // IsEmpty returns true if the SubscriptionChange doesn't request
74 // a change to any property of the Subscription.
75 func (change SubscriptionChange) IsEmpty() bool {
76 if change.StripeSubscription != nil {
79 if change.Plan != nil {
82 if change.Status != nil {
85 if change.Canceling != nil {
88 if change.TrialStart != nil {
91 if change.TrialEnd != nil {
94 if change.PeriodStart != nil {
97 if change.PeriodEnd != nil {
100 if change.CanceledAt != nil {
103 if change.LastNotified != nil {
106 if change.LastFailedCharge != nil {
109 if change.FailedChargeAttempts != nil {
115 // ApplyChange updates a Subscription based on the changes requested
116 // by a SubscriptionChange.
117 func (s *Subscription) ApplyChange(change SubscriptionChange) {
118 if change.StripeSubscription != nil {
119 s.StripeSubscription = *change.StripeSubscription
121 if change.Plan != nil {
122 s.Plan = *change.Plan
124 if change.Status != nil {
125 s.Status = *change.Status
127 if change.Canceling != nil {
128 s.Canceling = *change.Canceling
130 if change.TrialStart != nil {
131 s.TrialStart = *change.TrialStart
133 if change.TrialEnd != nil {
134 s.TrialEnd = *change.TrialEnd
136 if change.PeriodStart != nil {
137 s.PeriodStart = *change.PeriodStart
139 if change.PeriodEnd != nil {
140 s.PeriodEnd = *change.PeriodEnd
142 if change.CanceledAt != nil {
143 s.CanceledAt = *change.CanceledAt
145 if change.LastFailedCharge != nil {
146 s.LastFailedCharge = *change.LastFailedCharge
148 if change.LastNotified != nil {
149 s.LastNotified = *change.LastNotified
151 if change.FailedChargeAttempts != nil {
152 s.FailedChargeAttempts = *change.FailedChargeAttempts
156 type SubscriptionRequest struct {
157 UserID uuid.ID `json:"user_id"`
158 Email string `json:"email"`
159 StripeToken string `json:"stripe_token"`
160 Plan string `json:"plan"`
163 func (req SubscriptionRequest) Validate(user uuid.ID, admin bool) []api.RequestError {
164 var errors []api.RequestError
165 if req.UserID == nil {
166 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/user_id"})
167 } else if !req.UserID.Equal(user) && !admin {
168 errors = append(errors, api.RequestError{Slug: api.RequestErrAccessDenied, Field: "/user_id"})
171 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/email"})
173 if req.StripeToken == "" {
174 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/stripe_token"})
177 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/plan"})
179 var acceptablePlan bool
180 for _, p := range planOptions {
182 acceptablePlan = true
187 errors = append(errors, api.RequestError{Slug: api.RequestErrInvalidValue, Field: "/plan"})
193 func SubscriptionFromRequest(req SubscriptionRequest) Subscription {
196 StripeSubscription: req.StripeToken,
199 TrialStart: time.Now(),
203 // SubscriptionStats represents a set of statistics about our Subscription
204 // data that will be exposed to the Prometheus scraper.
205 type SubscriptionStats struct {
209 Plans map[string]int64
210 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
211 // Because of this, we can only report stats that will be identical across nodes, e.g. stats
212 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
213 // In the future, we'll need per-node metrics. For now, we'll make do.
215 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set:
216 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port,
217 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local.
218 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local.
219 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and
220 // contains the port number and a CNAME of the pod with the format auto-generated-name.my-svc.my-namespace.svc.cluster.local
221 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment
225 type SubscriptionStore interface {
227 CreateSubscription(sub Subscription) error
228 UpdateSubscription(id uuid.ID, change SubscriptionChange) error
229 DeleteSubscription(id uuid.ID) error
230 GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
231 GetSubscriptionStats() (SubscriptionStats, error)