ducky/subscriptions

Paddy 2015-06-14 Parent:56a2bef197cd Child:61c4ce5850da

1:f1a22fc2321d Go to Latest

ducky/subscriptions/subscription.go

Implement PostgreSQL support, drop subscription IDs. Create a Postgres object that wraps database/sql, so we can attach methods to it and fulfill interfaces. Create a postgres_init.sql script that will create the subscriptions table in a PostgreSQL database. Make our period type fulfill the driver.Valuer and driver.Scanner types, so it can be stored in and retrieved from SQL databases. Create a SubscriptionStats type, and add a method to our subscriptionStore interface that will allow us to retrieve current stats about the Subscriptions it is storing. Deprecated the ID property of our Subscription type, and use the Subscription.UserID property instead as our primary key. Subscriptions should be unique per user and we generally will want to access Subscriptions in the context of the User they belong to, so the UserID is a better primary key. This also means we removed the getSubscriptionByUserID method (and implementations) from our subscriptionStore, as getSubscriptions now fills that role. Implement our getSubscriptionStats method in the memstore. Implement the subscriptionStore interface on our new Postgres type. Run the subscription store tests on our Postgres type, as well, if the PG_TEST_DB environment variable is set. Round all our timestamps in our tests to the nearest millisecond, as Postgres silently truncates all timestamps to the nearest millisecond, and it was causing false test failures. Remove the tests for our getSubscriptionStoreByUser method, as that was removed.

History
paddy@0 1 package subscriptions
paddy@0 2
paddy@0 3 import (
paddy@1 4 "database/sql/driver"
paddy@0 5 "errors"
paddy@0 6 "time"
paddy@0 7
paddy@0 8 "code.secondbit.org/uuid.hg"
paddy@0 9 )
paddy@0 10
paddy@0 11 const (
paddy@0 12 // MonthlyPeriod represents a period of once a month.
paddy@0 13 MonthlyPeriod period = "monthly"
paddy@0 14 )
paddy@0 15
paddy@0 16 var (
paddy@0 17 // ErrSubscriptionAlreadyExists is returned when a Subscription
paddy@0 18 // with an identical ID already exists in the subscriptionStore.
paddy@0 19 ErrSubscriptionAlreadyExists = errors.New("Subscription already exists")
paddy@0 20 // ErrSubscriptionNotFound is returned when a single Subscription
paddy@0 21 // is acted upon or requested, but cannot be found.
paddy@0 22 ErrSubscriptionNotFound = errors.New("Subscription not found")
paddy@0 23 // ErrStripeCustomerAlreadyExists is returned when a Subscription
paddy@0 24 // is created or updates its StripeCustomer property, but that
paddy@0 25 // StripeCustomer is already associated with another Subscription.
paddy@0 26 ErrStripeCustomerAlreadyExists = errors.New("Stripe customer already assigned to another Subscription")
paddy@0 27 // ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
paddy@0 28 // is empty but is passed to subscriptionStore.UpdateSubscription
paddy@0 29 // anyways.
paddy@0 30 ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty")
paddy@0 31 // ErrNoSubscriptionID is returned when one or more Subscription IDs
paddy@0 32 // are required, but none are provided.
paddy@0 33 ErrNoSubscriptionID = errors.New("no Subscription ID provided")
paddy@1 34 // ErrPeriodInvalid is returned when attempting to use a period that
paddy@1 35 // is not a valid period.
paddy@1 36 ErrPeriodInvalid = errors.New("invalid period")
paddy@0 37 )
paddy@0 38
paddy@0 39 type period string
paddy@0 40
paddy@1 41 func (p period) Value() (driver.Value, error) {
paddy@1 42 return string(p), nil
paddy@1 43 }
paddy@1 44
paddy@1 45 func (p *period) Scan(src interface{}) error {
paddy@1 46 if src == nil {
paddy@1 47 *p = period("")
paddy@1 48 return nil
paddy@1 49 }
paddy@1 50 switch src.(type) {
paddy@1 51 case []byte:
paddy@1 52 *p = period(string(src.([]byte)))
paddy@1 53 return nil
paddy@1 54 case string:
paddy@1 55 *p = period(src.(string))
paddy@1 56 return nil
paddy@1 57 default:
paddy@1 58 return ErrPeriodInvalid
paddy@1 59 }
paddy@1 60 }
paddy@1 61
paddy@0 62 // Subscription represents the state of a user's payments. It holds
paddy@0 63 // metadata about the last time a user was charged, how much a user
paddy@0 64 // should be charged, how to charge a user and how much to charge
paddy@0 65 // the user.
paddy@0 66 type Subscription struct {
paddy@0 67 UserID uuid.ID
paddy@0 68 StripeCustomer string
paddy@0 69 Amount int
paddy@0 70 Period period
paddy@0 71 Created time.Time
paddy@0 72 BeginCharging time.Time
paddy@0 73 LastCharged time.Time
paddy@0 74 LastNotified time.Time
paddy@0 75 InLockout bool
paddy@0 76 }
paddy@0 77
paddy@0 78 // SubscriptionChange represents desired changes to a Subscription
paddy@0 79 // object. A nil value means that property should remain unchanged.
paddy@0 80 type SubscriptionChange struct {
paddy@0 81 StripeCustomer *string
paddy@0 82 Amount *int
paddy@0 83 Period *period
paddy@0 84 BeginCharging *time.Time
paddy@0 85 LastCharged *time.Time
paddy@0 86 LastNotified *time.Time
paddy@0 87 InLockout *bool
paddy@0 88 }
paddy@0 89
paddy@0 90 // IsEmpty returns true if the SubscriptionChange doesn't request
paddy@0 91 // a change to any property of the Subscription.
paddy@0 92 func (change SubscriptionChange) IsEmpty() bool {
paddy@0 93 if change.StripeCustomer != nil {
paddy@0 94 return false
paddy@0 95 }
paddy@0 96 if change.Amount != nil {
paddy@0 97 return false
paddy@0 98 }
paddy@0 99 if change.Period != nil {
paddy@0 100 return false
paddy@0 101 }
paddy@0 102 if change.BeginCharging != nil {
paddy@0 103 return false
paddy@0 104 }
paddy@0 105 if change.LastCharged != nil {
paddy@0 106 return false
paddy@0 107 }
paddy@0 108 if change.LastNotified != nil {
paddy@0 109 return false
paddy@0 110 }
paddy@0 111 if change.InLockout != nil {
paddy@0 112 return false
paddy@0 113 }
paddy@0 114 return true
paddy@0 115 }
paddy@0 116
paddy@0 117 // ApplyChange updates a Subscription based on the changes requested
paddy@0 118 // by a SubscriptionChange.
paddy@0 119 func (s *Subscription) ApplyChange(change SubscriptionChange) {
paddy@0 120 if change.StripeCustomer != nil {
paddy@0 121 s.StripeCustomer = *change.StripeCustomer
paddy@0 122 }
paddy@0 123 if change.Amount != nil {
paddy@0 124 s.Amount = *change.Amount
paddy@0 125 }
paddy@0 126 if change.Period != nil {
paddy@0 127 s.Period = *change.Period
paddy@0 128 }
paddy@0 129 if change.BeginCharging != nil {
paddy@0 130 s.BeginCharging = *change.BeginCharging
paddy@0 131 }
paddy@0 132 if change.LastCharged != nil {
paddy@0 133 s.LastCharged = *change.LastCharged
paddy@0 134 }
paddy@0 135 if change.LastNotified != nil {
paddy@0 136 s.LastNotified = *change.LastNotified
paddy@0 137 }
paddy@0 138 if change.InLockout != nil {
paddy@0 139 s.InLockout = *change.InLockout
paddy@0 140 }
paddy@0 141 }
paddy@0 142
paddy@0 143 // ByLastChargeDate allows us to sort a []Subscription by the LastCharged
paddy@0 144 // property, with the lowest LastCharged date first.
paddy@0 145 type ByLastChargeDate []Subscription
paddy@0 146
paddy@0 147 // Len returns the length the SubscriptionsByLastChargeDate. It fulfills
paddy@0 148 // the sort.Interface interface.
paddy@0 149 func (s ByLastChargeDate) Len() int {
paddy@0 150 return len(s)
paddy@0 151 }
paddy@0 152
paddy@0 153 // Swap puts the item in position i in position j, and the item in position
paddy@0 154 // j in position i. It fulfills the sort.Interface interface.
paddy@0 155 func (s ByLastChargeDate) Swap(i, j int) {
paddy@0 156 s[i], s[j] = s[j], s[i]
paddy@0 157 }
paddy@0 158
paddy@0 159 // Less returns true if the item in position i should be sorted before the
paddy@0 160 // item in position j.
paddy@0 161 func (s ByLastChargeDate) Less(i, j int) bool {
paddy@0 162 return s[i].LastCharged.Before(s[j].LastCharged)
paddy@0 163 }
paddy@0 164
paddy@1 165 // SubscriptionStats represents a set of statistics about our Subscription
paddy@1 166 // data that will be exposed to the Prometheus scraper.
paddy@1 167 type SubscriptionStats struct {
paddy@1 168 Number int64
paddy@1 169 TotalAmount int64
paddy@1 170 MeanAmount float64
paddy@1 171 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
paddy@1 172 // Because of this, we can only report stats that will be identical across nodes, e.g. stats
paddy@1 173 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
paddy@1 174 // In the future, we'll need per-node metrics. For now, we'll make do.
paddy@1 175 //
paddy@1 176 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set:
paddy@1 177 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port,
paddy@1 178 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local.
paddy@1 179 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local.
paddy@1 180 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and
paddy@1 181 // contains the port number and a CNAME of the pod with the format auto-generated-name.my-svc.my-namespace.svc.cluster.local
paddy@1 182 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment
paddy@1 183 // was omitted."
paddy@1 184 }
paddy@1 185
paddy@0 186 type subscriptionStore interface {
paddy@0 187 reset() error
paddy@0 188 createSubscription(sub Subscription) error
paddy@0 189 updateSubscription(id uuid.ID, change SubscriptionChange) error
paddy@0 190 deleteSubscription(id uuid.ID) error
paddy@0 191 listSubscriptionsLastChargedBefore(time.Time) ([]Subscription, error)
paddy@0 192 getSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
paddy@1 193 getSubscriptionStats() (SubscriptionStats, error)
paddy@0 194 }