ducky/subscriptions
ducky/subscriptions/subscription.go
Add an API and subscriptionsd . Create a barebones implementation of the API, including only methods to create a Subscription and retrieve the Subscription associated with a user. Also create a subscriptiond service that will bootstrap the service and stores, and get everything stood up.
1 package subscriptions
3 import (
4 "errors"
5 "time"
7 "code.secondbit.org/api.hg"
8 "code.secondbit.org/uuid.hg"
9 )
11 var (
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
24 // anyways.
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"}
32 Version string
33 )
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
38 // the user.
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"`
54 }
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
60 Plan *string
61 Status *string
62 Canceling *bool
63 TrialStart *time.Time
64 TrialEnd *time.Time
65 PeriodStart *time.Time
66 PeriodEnd *time.Time
67 CanceledAt *time.Time
68 FailedChargeAttempts *int
69 LastFailedCharge *time.Time
70 LastNotified *time.Time
71 }
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 {
77 return false
78 }
79 if change.Plan != nil {
80 return false
81 }
82 if change.Status != nil {
83 return false
84 }
85 if change.Canceling != nil {
86 return false
87 }
88 if change.TrialStart != nil {
89 return false
90 }
91 if change.TrialEnd != nil {
92 return false
93 }
94 if change.PeriodStart != nil {
95 return false
96 }
97 if change.PeriodEnd != nil {
98 return false
99 }
100 if change.CanceledAt != nil {
101 return false
102 }
103 if change.LastNotified != nil {
104 return false
105 }
106 if change.LastFailedCharge != nil {
107 return false
108 }
109 if change.FailedChargeAttempts != nil {
110 return false
111 }
112 return true
113 }
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
120 }
121 if change.Plan != nil {
122 s.Plan = *change.Plan
123 }
124 if change.Status != nil {
125 s.Status = *change.Status
126 }
127 if change.Canceling != nil {
128 s.Canceling = *change.Canceling
129 }
130 if change.TrialStart != nil {
131 s.TrialStart = *change.TrialStart
132 }
133 if change.TrialEnd != nil {
134 s.TrialEnd = *change.TrialEnd
135 }
136 if change.PeriodStart != nil {
137 s.PeriodStart = *change.PeriodStart
138 }
139 if change.PeriodEnd != nil {
140 s.PeriodEnd = *change.PeriodEnd
141 }
142 if change.CanceledAt != nil {
143 s.CanceledAt = *change.CanceledAt
144 }
145 if change.LastFailedCharge != nil {
146 s.LastFailedCharge = *change.LastFailedCharge
147 }
148 if change.LastNotified != nil {
149 s.LastNotified = *change.LastNotified
150 }
151 if change.FailedChargeAttempts != nil {
152 s.FailedChargeAttempts = *change.FailedChargeAttempts
153 }
154 }
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"`
161 }
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"})
169 }
170 if req.Email == "" {
171 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/email"})
172 }
173 if req.StripeToken == "" {
174 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/stripe_token"})
175 }
176 if req.Plan == "" {
177 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/plan"})
178 } else {
179 var acceptablePlan bool
180 for _, p := range planOptions {
181 if req.Plan == p {
182 acceptablePlan = true
183 break
184 }
185 }
186 if !acceptablePlan {
187 errors = append(errors, api.RequestError{Slug: api.RequestErrInvalidValue, Field: "/plan"})
188 }
189 }
190 return errors
191 }
193 func SubscriptionFromRequest(req SubscriptionRequest) Subscription {
194 return Subscription{
195 UserID: req.UserID,
196 StripeSubscription: req.StripeToken,
197 Plan: req.Plan,
198 Created: time.Now(),
199 TrialStart: time.Now(),
200 }
201 }
203 // SubscriptionStats represents a set of statistics about our Subscription
204 // data that will be exposed to the Prometheus scraper.
205 type SubscriptionStats struct {
206 Number int64
207 Canceling int64
208 Failing int64
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.
214 //
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
222 // was omitted."
223 }
225 type SubscriptionStore interface {
226 Reset() error
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)
232 }