diff --git a/config/config.go b/config/config.go index 402349f..14c15d9 100644 --- a/config/config.go +++ b/config/config.go @@ -123,10 +123,12 @@ type serverConfig struct { } type subscriptionsConfig struct { - Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"` - StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"` - StripeSecret string `yaml:"stripe_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET"` - StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"` + Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"` + StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"` + StripeSecretKey string `yaml:"stripe_secret_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"` + StripeEndpointSecret string `yaml:"stripe_endpoint_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_ENDPOINT_SECRET"` + StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"` + StandardPrice string `yaml:"-"` } type sentryConfig struct { @@ -189,7 +191,7 @@ func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie MaxAge: maxAge, Secure: !c.Security.InsecureCookies, HttpOnly: true, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, } } diff --git a/main.go b/main.go index 5c03342..468bd8d 100644 --- a/main.go +++ b/main.go @@ -216,6 +216,7 @@ func main() { // MVC Handlers summaryHandler := routes.NewSummaryHandler(summaryService, userService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService) + subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService) leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService) homeHandler := routes.NewHomeHandler(keyValueService) loginHandler := routes.NewLoginHandler(userService, mailService) @@ -253,6 +254,7 @@ func main() { summaryHandler.RegisterRoutes(rootRouter) leaderboardHandler.RegisterRoutes(rootRouter) settingsHandler.RegisterRoutes(rootRouter) + subscriptionHandler.RegisterRoutes(rootRouter) relayHandler.RegisterRoutes(rootRouter) // API route registrations diff --git a/models/user.go b/models/user.go index e684f1e..0317935 100644 --- a/models/user.go +++ b/models/user.go @@ -35,7 +35,6 @@ type User struct { ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"` PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"` SubscribedUntil *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` - StripeCustomerId string `json:"-"` } type Login struct { diff --git a/models/view/settings.go b/models/view/settings.go index 7e55f5a..314abdb 100644 --- a/models/view/settings.go +++ b/models/view/settings.go @@ -3,14 +3,15 @@ package view import "github.com/muety/wakapi/models" type SettingsViewModel struct { - User *models.User - LanguageMappings []*models.LanguageMapping - Aliases []*SettingsVMCombinedAlias - Labels []*SettingsVMCombinedLabel - Projects []string - ApiKey string - Success string - Error string + User *models.User + LanguageMappings []*models.LanguageMapping + Aliases []*SettingsVMCombinedAlias + Labels []*SettingsVMCombinedLabel + Projects []string + SubscriptionPrice string + ApiKey string + Success string + Error string } type SettingsVMCombinedAlias struct { @@ -24,6 +25,10 @@ type SettingsVMCombinedLabel struct { Values []string } +func (s *SettingsViewModel) SubscriptionsEnabled() bool { + return s.SubscriptionPrice != "" +} + func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel { s.Success = m return s diff --git a/routes/settings.go b/routes/settings.go index 92f384b..d6d47e4 100644 --- a/routes/settings.go +++ b/routes/settings.go @@ -733,14 +733,21 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode return &view.SettingsViewModel{Error: criticalError} } + // subscriptions + var subscriptionPrice string + if h.config.Subscriptions.Enabled { + subscriptionPrice = h.config.Subscriptions.StandardPrice + } + return &view.SettingsViewModel{ - User: user, - LanguageMappings: mappings, - Aliases: combinedAliases, - Labels: combinedLabels, - Projects: projects, - ApiKey: user.ApiKey, - Success: r.URL.Query().Get("success"), - Error: r.URL.Query().Get("error"), + User: user, + LanguageMappings: mappings, + Aliases: combinedAliases, + Labels: combinedLabels, + Projects: projects, + ApiKey: user.ApiKey, + SubscriptionPrice: subscriptionPrice, + Success: r.URL.Query().Get("success"), + Error: r.URL.Query().Get("error"), } } diff --git a/routes/subscription.go b/routes/subscription.go index 69b34cc..5d36e48 100644 --- a/routes/subscription.go +++ b/routes/subscription.go @@ -1,14 +1,21 @@ package routes import ( + "encoding/json" + "errors" "fmt" + "github.com/emvi/logbuch" "github.com/gorilla/mux" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/services" "github.com/stripe/stripe-go/v74" - portalSession "github.com/stripe/stripe-go/v74/billingportal/session" - checkoutSession "github.com/stripe/stripe-go/v74/checkout/session" + stripePortalSession "github.com/stripe/stripe-go/v74/billingportal/session" + stripeCheckoutSession "github.com/stripe/stripe-go/v74/checkout/session" + stripeCustomer "github.com/stripe/stripe-go/v74/customer" + stripePrice "github.com/stripe/stripe-go/v74/price" + "github.com/stripe/stripe-go/v74/webhook" + "io/ioutil" "net/http" "time" ) @@ -27,7 +34,15 @@ func NewSubscriptionHandler( config := conf.Get() if config.Subscriptions.Enabled { - stripe.Key = config.Subscriptions.StripeApiKey + stripe.Key = config.Subscriptions.StripeSecretKey + + price, err := stripePrice.Get(config.Subscriptions.StandardPriceId, nil) + if err != nil { + logbuch.Fatal("failed to fetch stripe plan details: %v", err) + } + config.Subscriptions.StandardPrice = fmt.Sprintf("%2.f €", price.UnitAmountDecimal/100.0) // TODO: respect actual currency + + logbuch.Info("enabling subscriptions with stripe payment for %s / month", config.Subscriptions.StandardPrice) } return &SubscriptionHandler{ @@ -45,16 +60,17 @@ func (h *SubscriptionHandler) RegisterRoutes(router *mux.Router) { return } - r := router.PathPrefix("/subscription").Subrouter() - r.Use( + subRouterPublic := router.PathPrefix("/subscription").Subrouter() + subRouterPublic.Path("/success").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutSuccess) + subRouterPublic.Path("/cancel").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutCancel) + subRouterPublic.Path("/webhook").Methods(http.MethodPost).HandlerFunc(h.PostWebhook) + + subRouterPrivate := subRouterPublic.PathPrefix("").Subrouter() + subRouterPrivate.Use( middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler, ) - r.Path("/success").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutSuccess) - r.Path("/cancel").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutCancel) - r.Path("/checkout").Methods(http.MethodPost).HandlerFunc(h.PostCheckout) - r.Path("/portal").Methods(http.MethodPost).HandlerFunc(h.PostPortal) - - router.Path("/webhook").Methods(http.MethodPost).HandlerFunc(h.PostWebhook) + subRouterPrivate.Path("/checkout").Methods(http.MethodPost).HandlerFunc(h.PostCheckout) + subRouterPrivate.Path("/portal").Methods(http.MethodPost).HandlerFunc(h.PostPortal) } func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Request) { @@ -64,12 +80,12 @@ func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Reques user := middlewares.GetPrincipal(r) if user.Email == "" { - http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "missing e-mail address"), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "missing e-mail address"), http.StatusFound) return } if err := r.ParseForm(); err != nil { - http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "missing form values"), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "missing form values"), http.StatusFound) return } @@ -81,15 +97,16 @@ func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Reques Quantity: stripe.Int64(1), }, }, - CustomerEmail: &user.Email, - SuccessURL: stripe.String(fmt.Sprintf("%s%s/subscription/success", h.config.Server.PublicUrl, h.config.Server.BasePath)), - CancelURL: stripe.String(fmt.Sprintf("%s%s/subscription/cacnel", h.config.Server.PublicUrl, h.config.Server.BasePath)), + CustomerEmail: &user.Email, + ClientReferenceID: &user.Email, + SuccessURL: stripe.String(fmt.Sprintf("%s%s/subscription/success", h.config.Server.PublicUrl, h.config.Server.BasePath)), + CancelURL: stripe.String(fmt.Sprintf("%s%s/subscription/cancel", h.config.Server.PublicUrl, h.config.Server.BasePath)), } - session, err := checkoutSession.New(checkoutParams) + session, err := stripeCheckoutSession.New(checkoutParams) if err != nil { conf.Log().Request(r).Error("failed to create stripe checkout session: %v", err) - http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "something went wrong"), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "something went wrong"), http.StatusFound) return } @@ -102,20 +119,26 @@ func (h *SubscriptionHandler) PostPortal(w http.ResponseWriter, r *http.Request) } user := middlewares.GetPrincipal(r) - if user.StripeCustomerId == "" { - http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "missing stripe customer reference, please contact us!"), http.StatusFound) + if user.Email == "" { + http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "no subscription found with your e-mail address, please contact us!"), http.StatusFound) + return + } + + customer, err := h.findStripeCustomerByEmail(user.Email) + if err != nil { + http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "no subscription found with your e-mail address, please contact us!"), http.StatusFound) return } portalParams := &stripe.BillingPortalSessionParams{ - Customer: &user.StripeCustomerId, + Customer: &customer.ID, ReturnURL: &h.config.Server.PublicUrl, } - session, err := portalSession.New(portalParams) + session, err := stripePortalSession.New(portalParams) if err != nil { conf.Log().Request(r).Error("failed to create stripe portal session: %v", err) - http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "something went wrong"), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s#subscription", h.config.Server.BasePath, "something went wrong"), http.StatusFound) return } @@ -123,13 +146,86 @@ func (h *SubscriptionHandler) PostPortal(w http.ResponseWriter, r *http.Request) } func (h *SubscriptionHandler) PostWebhook(w http.ResponseWriter, r *http.Request) { - // TODO: implement + bodyReader := http.MaxBytesReader(w, r.Body, int64(65536)) + payload, err := ioutil.ReadAll(bodyReader) + if err != nil { + conf.Log().Request(r).Error("error in stripe webhook request: %v", err) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + event, err := webhook.ConstructEventWithOptions(payload, r.Header.Get("Stripe-Signature"), h.config.Subscriptions.StripeEndpointSecret, webhook.ConstructEventOptions{ + IgnoreAPIVersionMismatch: true, + }) + if err != nil { + conf.Log().Request(r).Error("stripe webhook signature verification failed: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + switch event.Type { + case "customer.subscription.deleted", + "customer.subscription.updated", + "customer.subscription.created": + subscription, customer, err := h.handleParseSubscription(w, r, event) + if err != nil { + return + } + logbuch.Info("received stripe subscription event of type '%s' for subscription '%d' (customer '%s' with email '%s').", event.Type, subscription.ID, customer.ID, customer.Email) + // TODO: handle + // if status == 'active', set active subscription date to current_period_end + // if status == 'canceled' or 'unpaid', clear active subscription date, if < now + // example payload: https://pastr.de/p/k7bx3alx38b1iawo6amtx09k + default: + logbuch.Warn("got stripe event '%s' with no handler defined", event.Type) + } + + w.WriteHeader(http.StatusOK) } func (h *SubscriptionHandler) GetCheckoutSuccess(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?success=%s", h.config.Server.BasePath, "you have successfully subscribed to wakapi!"), http.StatusFound) + http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s#subscription", h.config.Server.BasePath, "you have successfully subscribed to Wakapi!"), http.StatusFound) } func (h *SubscriptionHandler) GetCheckoutCancel(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription", h.config.Server.BasePath), http.StatusFound) } + +func (h *SubscriptionHandler) handleParseSubscription(w http.ResponseWriter, r *http.Request, event stripe.Event) (*stripe.Subscription, *stripe.Customer, error) { + var subscription stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil { + conf.Log().Request(r).Error("failed to parse stripe webhook payload: %v", err) + w.WriteHeader(http.StatusBadRequest) + return nil, nil, err + } + + customer, err := stripeCustomer.Get(subscription.Customer.ID, nil) + if err != nil { + conf.Log().Request(r).Error("failed to fetch stripe customer (%s): %v", subscription.Customer.ID, err) + w.WriteHeader(http.StatusBadRequest) + return nil, nil, err + } + + logbuch.Info("associated stripe customer %s with user %s", customer.ID, customer.Email) + + return &subscription, customer, nil +} + +func (h *SubscriptionHandler) findStripeCustomerByEmail(email string) (*stripe.Customer, error) { + params := &stripe.CustomerSearchParams{ + SearchParams: stripe.SearchParams{ + Query: fmt.Sprintf(`email:"%s"`, email), + }, + } + + results := stripeCustomer.Search(params) + if err := results.Err(); err != nil { + return nil, err + } + + if results.Next() { + return results.Customer(), nil + } else { + return nil, errors.New("no customer found with given criteria") + } +} diff --git a/views/settings.tpl.html b/views/settings.tpl.html index 071afc9..b17c17d 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -49,9 +49,11 @@
  • Integrations
  • -
  • + {{ if .SubscriptionsEnabled }} +
  • Subscription
  • + {{ end }}
  • Danger Zone
  • @@ -605,14 +607,12 @@
    Badge + alt="Badge"/>
    + readonly>
    @@ -625,22 +625,19 @@ + readonly>
    Shields.io badge + alt="Shields.io badge"/>
    + readonly>
    {{ end }} @@ -677,21 +674,23 @@ + {{ if .SubscriptionsEnabled }}
    -
    +
    Subscription - Lorem ipsum dolor sit amet + By default, this Wakapi instance will only store historical coding activity for 12 months. However, if you want to support the project, you can opt for a paid subscription for {{ .SubscriptionPrice }} / month to get unlimited history with no restrictions.
    - +
    + {{ end }}