feat(wip): implement stripe webhooks

This commit is contained in:
Ferdinand Mütsch 2022-12-20 23:08:59 +01:00
parent f39ecc46bd
commit 05ea05cdf4
7 changed files with 171 additions and 61 deletions

View File

@ -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,
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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"),
}
}

View File

@ -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")
}
}

View File

@ -49,9 +49,11 @@
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('integrations'), 'hover:text-gray-500': !isActive('integrations') }">
<a href="settings#integrations" @click="updateTab">Integrations</a>
</li>
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('integrations'), 'hover:text-gray-500': !isActive('subscription') }">
{{ if .SubscriptionsEnabled }}
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('subscription'), 'hover:text-gray-500': !isActive('subscription') }">
<a href="settings#subscription" @click="updateTab">Subscription</a>
</li>
{{ end }}
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('danger_zone'), 'hover:text-gray-500': !isActive('danger_zone') }">
<a href="settings#danger_zone" @click="updateTab">Danger Zone</a>
</li>
@ -605,14 +607,12 @@
<div class="flex items-center w-1/3">
<img class="with-url-src"
src="api/badge/{{ .User.ID }}/interval:today?label=today"
alt="Badge"
/>
alt="Badge"/>
</div>
<input
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="%s/api/badge/{{ .User.ID }}/interval:today?label=today"
readonly
>
readonly>
</div>
<div class="flex space-x-4 mt-4">
@ -625,22 +625,19 @@
<input
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="%s/api/badge/{{ .User.ID }}/{{ .User.ID }}/interval:30_days?label=last 30d"
readonly
>
readonly>
</div>
<div class="flex space-x-4 mt-4">
<div class="flex items-center w-1/3">
<img class="with-url-src"
src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
alt="Shields.io badge"
/>
alt="Shields.io badge"/>
</div>
<input
class="with-url-value w-2/3 font-mono text-xs appearance-none bg-gray-850 text-gray-500 outline-none rounded py-2 px-4 cursor-not-allowed"
value="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
readonly
>
readonly>
</div>
{{ end }}
</div>
@ -677,21 +674,23 @@
</div>
</div>
{{ if .SubscriptionsEnabled }}
<div v-cloak id="subscription" class="tab flex flex-col space-y-4" v-if="isActive('subscription')">
<div class="w-full lg:w-3/4">
<form action="" method="post" class="flex mb-8" id="form-subscription-checkout">
<form action="subscription/checkout" method="post" class="flex mb-8" id="form-subscription-checkout">
<div class="w-1/2 mr-4 inline-block">
<span class="font-semibold text-gray-300">Subscription</span>
<span class="block text-sm text-gray-600">
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.
</span>
</div>
<div class="w-1/2 ml-4 flex items-center">
<button type="submit" class="btn-primary ml-1">Subscribe</button>
<button type="submit" class="btn-primary ml-1">Subscribe ({{ .SubscriptionPrice }} / mo)</button>
</div>
</form>
</div>
</div>
{{ end }}
<div v-cloak id="danger_zone" class="tab flex flex-col space-y-4" v-if="isActive('danger_zone')">
<div class="w-full lg:w-3/4">