1
0
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:
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 { 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,
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

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