mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
feat(wip): implement stripe webhooks
This commit is contained in:
parent
f39ecc46bd
commit
05ea05cdf4
@ -123,10 +123,12 @@ type serverConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type subscriptionsConfig struct {
|
type subscriptionsConfig struct {
|
||||||
Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"`
|
Enabled bool `yaml:"enabled" default:"false" env:"WAKAPI_SUBSCRIPTIONS_ENABLED"`
|
||||||
StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"`
|
StripeApiKey string `yaml:"stripe_api_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_API_KEY"`
|
||||||
StripeSecret string `yaml:"stripe_secret" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET"`
|
StripeSecretKey string `yaml:"stripe_secret_key" env:"WAKAPI_SUBSCRIPTIONS_STRIPE_SECRET_KEY"`
|
||||||
StandardPriceId string `yaml:"standard_price_id" env:"WAKAPI_SUBSCRIPTIONS_STANDARD_PRICE_ID"`
|
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 {
|
type sentryConfig struct {
|
||||||
@ -189,7 +191,7 @@ func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie
|
|||||||
MaxAge: maxAge,
|
MaxAge: maxAge,
|
||||||
Secure: !c.Security.InsecureCookies,
|
Secure: !c.Security.InsecureCookies,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
2
main.go
2
main.go
@ -216,6 +216,7 @@ func main() {
|
|||||||
// MVC Handlers
|
// MVC Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||||
|
subscriptionHandler := routes.NewSubscriptionHandler(userService, mailService)
|
||||||
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
|
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
|
||||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||||
@ -253,6 +254,7 @@ func main() {
|
|||||||
summaryHandler.RegisterRoutes(rootRouter)
|
summaryHandler.RegisterRoutes(rootRouter)
|
||||||
leaderboardHandler.RegisterRoutes(rootRouter)
|
leaderboardHandler.RegisterRoutes(rootRouter)
|
||||||
settingsHandler.RegisterRoutes(rootRouter)
|
settingsHandler.RegisterRoutes(rootRouter)
|
||||||
|
subscriptionHandler.RegisterRoutes(rootRouter)
|
||||||
relayHandler.RegisterRoutes(rootRouter)
|
relayHandler.RegisterRoutes(rootRouter)
|
||||||
|
|
||||||
// API route registrations
|
// API route registrations
|
||||||
|
@ -35,7 +35,6 @@ type User struct {
|
|||||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
PublicLeaderboard 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"`
|
SubscribedUntil *CustomTime `json:"-" gorm:"type:timestamp" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||||
StripeCustomerId string `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Login struct {
|
type Login struct {
|
||||||
|
@ -3,14 +3,15 @@ package view
|
|||||||
import "github.com/muety/wakapi/models"
|
import "github.com/muety/wakapi/models"
|
||||||
|
|
||||||
type SettingsViewModel struct {
|
type SettingsViewModel struct {
|
||||||
User *models.User
|
User *models.User
|
||||||
LanguageMappings []*models.LanguageMapping
|
LanguageMappings []*models.LanguageMapping
|
||||||
Aliases []*SettingsVMCombinedAlias
|
Aliases []*SettingsVMCombinedAlias
|
||||||
Labels []*SettingsVMCombinedLabel
|
Labels []*SettingsVMCombinedLabel
|
||||||
Projects []string
|
Projects []string
|
||||||
ApiKey string
|
SubscriptionPrice string
|
||||||
Success string
|
ApiKey string
|
||||||
Error string
|
Success string
|
||||||
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsVMCombinedAlias struct {
|
type SettingsVMCombinedAlias struct {
|
||||||
@ -24,6 +25,10 @@ type SettingsVMCombinedLabel struct {
|
|||||||
Values []string
|
Values []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingsViewModel) SubscriptionsEnabled() bool {
|
||||||
|
return s.SubscriptionPrice != ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||||
s.Success = m
|
s.Success = m
|
||||||
return s
|
return s
|
||||||
|
@ -733,14 +733,21 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
|||||||
return &view.SettingsViewModel{Error: criticalError}
|
return &view.SettingsViewModel{Error: criticalError}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subscriptions
|
||||||
|
var subscriptionPrice string
|
||||||
|
if h.config.Subscriptions.Enabled {
|
||||||
|
subscriptionPrice = h.config.Subscriptions.StandardPrice
|
||||||
|
}
|
||||||
|
|
||||||
return &view.SettingsViewModel{
|
return &view.SettingsViewModel{
|
||||||
User: user,
|
User: user,
|
||||||
LanguageMappings: mappings,
|
LanguageMappings: mappings,
|
||||||
Aliases: combinedAliases,
|
Aliases: combinedAliases,
|
||||||
Labels: combinedLabels,
|
Labels: combinedLabels,
|
||||||
Projects: projects,
|
Projects: projects,
|
||||||
ApiKey: user.ApiKey,
|
ApiKey: user.ApiKey,
|
||||||
Success: r.URL.Query().Get("success"),
|
SubscriptionPrice: subscriptionPrice,
|
||||||
Error: r.URL.Query().Get("error"),
|
Success: r.URL.Query().Get("success"),
|
||||||
|
Error: r.URL.Query().Get("error"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
portalSession "github.com/stripe/stripe-go/v74/billingportal/session"
|
stripePortalSession "github.com/stripe/stripe-go/v74/billingportal/session"
|
||||||
checkoutSession "github.com/stripe/stripe-go/v74/checkout/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"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -27,7 +34,15 @@ func NewSubscriptionHandler(
|
|||||||
config := conf.Get()
|
config := conf.Get()
|
||||||
|
|
||||||
if config.Subscriptions.Enabled {
|
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{
|
return &SubscriptionHandler{
|
||||||
@ -45,16 +60,17 @@ func (h *SubscriptionHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r := router.PathPrefix("/subscription").Subrouter()
|
subRouterPublic := router.PathPrefix("/subscription").Subrouter()
|
||||||
r.Use(
|
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,
|
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
|
||||||
)
|
)
|
||||||
r.Path("/success").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutSuccess)
|
subRouterPrivate.Path("/checkout").Methods(http.MethodPost).HandlerFunc(h.PostCheckout)
|
||||||
r.Path("/cancel").Methods(http.MethodGet).HandlerFunc(h.GetCheckoutCancel)
|
subRouterPrivate.Path("/portal").Methods(http.MethodPost).HandlerFunc(h.PostPortal)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Request) {
|
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)
|
user := middlewares.GetPrincipal(r)
|
||||||
if user.Email == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,15 +97,16 @@ func (h *SubscriptionHandler) PostCheckout(w http.ResponseWriter, r *http.Reques
|
|||||||
Quantity: stripe.Int64(1),
|
Quantity: stripe.Int64(1),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CustomerEmail: &user.Email,
|
CustomerEmail: &user.Email,
|
||||||
SuccessURL: stripe.String(fmt.Sprintf("%s%s/subscription/success", h.config.Server.PublicUrl, h.config.Server.BasePath)),
|
ClientReferenceID: &user.Email,
|
||||||
CancelURL: stripe.String(fmt.Sprintf("%s%s/subscription/cacnel", h.config.Server.PublicUrl, h.config.Server.BasePath)),
|
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 {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("failed to create stripe checkout session: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,20 +119,26 @@ func (h *SubscriptionHandler) PostPortal(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := middlewares.GetPrincipal(r)
|
user := middlewares.GetPrincipal(r)
|
||||||
if user.StripeCustomerId == "" {
|
if user.Email == "" {
|
||||||
http.Redirect(w, r, fmt.Sprintf("%s/settings#subscription?error=%s", h.config.Server.BasePath, "missing stripe customer reference, please contact us!"), http.StatusFound)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
portalParams := &stripe.BillingPortalSessionParams{
|
portalParams := &stripe.BillingPortalSessionParams{
|
||||||
Customer: &user.StripeCustomerId,
|
Customer: &customer.ID,
|
||||||
ReturnURL: &h.config.Server.PublicUrl,
|
ReturnURL: &h.config.Server.PublicUrl,
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := portalSession.New(portalParams)
|
session, err := stripePortalSession.New(portalParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conf.Log().Request(r).Error("failed to create stripe portal session: %v", err)
|
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
|
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) {
|
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) {
|
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) {
|
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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -49,9 +49,11 @@
|
|||||||
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('integrations'), 'hover:text-gray-500': !isActive('integrations') }">
|
<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>
|
<a href="settings#integrations" @click="updateTab">Integrations</a>
|
||||||
</li>
|
</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>
|
<a href="settings#subscription" @click="updateTab">Subscription</a>
|
||||||
</li>
|
</li>
|
||||||
|
{{ end }}
|
||||||
<li class="font-semibold text-2xl" v-bind:class="{ 'text-gray-300': isActive('danger_zone'), 'hover:text-gray-500': !isActive('danger_zone') }">
|
<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>
|
<a href="settings#danger_zone" @click="updateTab">Danger Zone</a>
|
||||||
</li>
|
</li>
|
||||||
@ -605,14 +607,12 @@
|
|||||||
<div class="flex items-center w-1/3">
|
<div class="flex items-center w-1/3">
|
||||||
<img class="with-url-src"
|
<img class="with-url-src"
|
||||||
src="api/badge/{{ .User.ID }}/interval:today?label=today"
|
src="api/badge/{{ .User.ID }}/interval:today?label=today"
|
||||||
alt="Badge"
|
alt="Badge"/>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<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"
|
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"
|
value="%s/api/badge/{{ .User.ID }}/interval:today?label=today"
|
||||||
readonly
|
readonly>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex space-x-4 mt-4">
|
<div class="flex space-x-4 mt-4">
|
||||||
@ -625,22 +625,19 @@
|
|||||||
<input
|
<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"
|
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"
|
value="%s/api/badge/{{ .User.ID }}/{{ .User.ID }}/interval:30_days?label=last 30d"
|
||||||
readonly
|
readonly>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex space-x-4 mt-4">
|
<div class="flex space-x-4 mt-4">
|
||||||
<div class="flex items-center w-1/3">
|
<div class="flex items-center w-1/3">
|
||||||
<img class="with-url-src"
|
<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"
|
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>
|
</div>
|
||||||
<input
|
<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"
|
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"
|
value="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&label=last 30d"
|
||||||
readonly
|
readonly>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
@ -677,21 +674,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ if .SubscriptionsEnabled }}
|
||||||
<div v-cloak id="subscription" class="tab flex flex-col space-y-4" v-if="isActive('subscription')">
|
<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">
|
<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">
|
<div class="w-1/2 mr-4 inline-block">
|
||||||
<span class="font-semibold text-gray-300">Subscription</span>
|
<span class="font-semibold text-gray-300">Subscription</span>
|
||||||
<span class="block text-sm text-gray-600">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 ml-4 flex items-center">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<div v-cloak id="danger_zone" class="tab flex flex-col space-y-4" v-if="isActive('danger_zone')">
|
<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">
|
<div class="w-full lg:w-3/4">
|
||||||
|
Loading…
Reference in New Issue
Block a user