diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
index 82f8c4c..1af3cc1 100644
--- a/backend/cmd/api/main.go
+++ b/backend/cmd/api/main.go
@@ -61,7 +61,7 @@ func main() {
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, userModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel)
- stripeHandler := handlers.NewStripeHandler(userModel, cfg)
+ stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
authenticate := middleware.Authenticate(userModel)
diff --git a/backend/cmd/subscription-sweep/main.go b/backend/cmd/subscription-sweep/main.go
new file mode 100644
index 0000000..8bc9bac
--- /dev/null
+++ b/backend/cmd/subscription-sweep/main.go
@@ -0,0 +1,68 @@
+// Package main runs a daily (cron-invoked) job: for paid tiers without an
+// active or trialling Stripe subscription, disable Firebase login and turn
+// off all alert rules until billing is restored via Stripe webhook / checkout.
+package main
+
+import (
+ "context"
+ "log"
+
+ "github.com/joho/godotenv"
+
+ "github.com/kjannette/koin-ping/backend/internal/config"
+ "github.com/kjannette/koin-ping/backend/internal/database"
+ "github.com/kjannette/koin-ping/backend/internal/firebase"
+ "github.com/kjannette/koin-ping/backend/internal/models"
+)
+
+func main() {
+ _ = godotenv.Load()
+
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("Failed to load config: %v", err)
+ }
+
+ if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
+ log.Fatalf("Failed to initialize Firebase: %v", err)
+ }
+
+ pool, connErr := database.Connect(cfg.DSN())
+ if connErr != nil {
+ log.Fatalf("Failed to connect to database: %v", connErr)
+ }
+
+ defer database.Close()
+
+ ctx := context.Background()
+ userModel := models.NewUserModel(pool)
+ alertModel := models.NewAlertRuleModel(pool)
+
+ users, listErr := userModel.ListPaidUsersWithoutActiveSubscription(ctx)
+ if listErr != nil {
+ log.Fatalf("Failed to list lapsed subscriptions: %v", listErr)
+ }
+
+ if len(users) == 0 {
+ log.Println("Subscription sweep: no lapsed paid users")
+ return
+ }
+
+ for _, u := range users {
+ if disableErr := firebase.SetUserDisabled(ctx, u.FirebaseUID, true); disableErr != nil {
+ log.Printf("Subscription sweep: Firebase disable failed user %s: %v", u.ID, disableErr)
+ continue
+ }
+
+ n, rulesErr := alertModel.DisableAllForUser(ctx, u.ID)
+ if rulesErr != nil {
+ log.Printf("Subscription sweep: disabling alerts failed for user %s: %v", u.ID, rulesErr)
+ continue
+ }
+
+ log.Printf(
+ "Subscription sweep: suspended user %s (%s tier, status=%s), disabled %d alert rules",
+ u.ID, u.SubscriptionTier, u.SubscriptionStatus, n,
+ )
+ }
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index f67c592..d066b11 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -32,12 +32,14 @@ type Config struct {
ResendAPIKey string
EmailFrom string
DigestIntervalHours int
- StripeSecretKey string
- StripeWebhookSecret string
- StripePriceIDPremium string
- StripePriceIDPro string
- StripePublishableKey string
- FrontendURL string
+ StripeSecretKey string
+ StripeWebhookSecret string
+ StripePriceIDPremium string
+ StripePriceIDPro string
+ StripePriceIDPremiumAnnual string
+ StripePriceIDProAnnual string
+ StripePublishableKey string
+ FrontendURL string
}
// Load reads configuration from environment variables and returns a Config.
@@ -58,12 +60,14 @@ func Load() (*Config, error) {
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping "),
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
- StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
- StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
- StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
- StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
- StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
- FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
+ StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
+ StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
+ StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
+ StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
+ StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
+ StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
+ StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
+ FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
}
if cfg.PollIntervalMS < minPollIntervalMS {
@@ -97,10 +101,13 @@ func (c *Config) DSN() string {
// TierForPriceID maps a Stripe price ID back to the corresponding
// subscription tier. Returns empty string if the price is unrecognised.
func (c *Config) TierForPriceID(priceID string) string {
+ if priceID == "" {
+ return ""
+ }
switch priceID {
- case c.StripePriceIDPremium:
+ case c.StripePriceIDPremium, c.StripePriceIDPremiumAnnual:
return "premium"
- case c.StripePriceIDPro:
+ case c.StripePriceIDPro, c.StripePriceIDProAnnual:
return "pro"
default:
return ""
diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go
index 5f26b77..f1ddca2 100644
--- a/backend/internal/config/config_test.go
+++ b/backend/internal/config/config_test.go
@@ -6,8 +6,10 @@ func TestTierForPriceID(t *testing.T) {
t.Parallel()
cfg := &Config{
- StripePriceIDPremium: "price_premium_123",
- StripePriceIDPro: "price_pro_456",
+ StripePriceIDPremium: "price_premium_123",
+ StripePriceIDPro: "price_pro_456",
+ StripePriceIDPremiumAnnual: "price_premium_yr",
+ StripePriceIDProAnnual: "price_pro_yr",
}
tests := []struct {
@@ -16,6 +18,8 @@ func TestTierForPriceID(t *testing.T) {
}{
{"price_premium_123", "premium"},
{"price_pro_456", "pro"},
+ {"price_premium_yr", "premium"},
+ {"price_pro_yr", "pro"},
{"price_unknown", ""},
{"", ""},
}
diff --git a/backend/internal/firebase/users.go b/backend/internal/firebase/users.go
new file mode 100644
index 0000000..498043f
--- /dev/null
+++ b/backend/internal/firebase/users.go
@@ -0,0 +1,22 @@
+package firebase
+
+import (
+ "context"
+ "fmt"
+
+ "firebase.google.com/go/v4/auth"
+)
+
+// SetUserDisabled updates the Firebase user record's disabled flag.
+func SetUserDisabled(ctx context.Context, uid string, disabled bool) error {
+ if authClient == nil {
+ return fmt.Errorf("firebase auth not initialized") //nolint:err113
+ }
+ if uid == "" {
+ return nil
+ }
+
+ params := (&auth.UserToUpdate{}).Disabled(disabled)
+ _, err := authClient.UpdateUser(ctx, uid, params)
+ return err
+}
diff --git a/backend/internal/handlers/account.go b/backend/internal/handlers/account.go
index 5bcdb42..05c8ec4 100644
--- a/backend/internal/handlers/account.go
+++ b/backend/internal/handlers/account.go
@@ -38,8 +38,8 @@ type accountResponse struct {
var tierPlanLabels = map[domain.SubscriptionTier]string{ //nolint:gochecknoglobals
domain.TierFree: "Free Trial",
- domain.TierPremium: "Premium / $1.99 mo",
- domain.TierPro: "Pro / $11.99 mo",
+ domain.TierPremium: "Premium / $8.78 mo",
+ domain.TierPro: "Pro / $16.78 mo",
}
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/handlers/stripe.go b/backend/internal/handlers/stripe.go
index 624428c..8f8233f 100644
--- a/backend/internal/handlers/stripe.go
+++ b/backend/internal/handlers/stripe.go
@@ -1,36 +1,86 @@
package handlers
import (
+ "context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
- "github.com/stripe/stripe-go/v82"
- portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
- checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
- "github.com/stripe/stripe-go/v82/webhook"
-
+ kpfirebase "github.com/kjannette/koin-ping/backend/internal/firebase"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models"
+ "github.com/stripe/stripe-go/v82"
+ portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
+ checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
+ "github.com/stripe/stripe-go/v82/webhook"
)
const webhookMaxBodyBytes = 65536
type StripeHandler struct {
- users *models.UserModel
- cfg *config.Config
+ users *models.UserModel
+ alerts *models.AlertRuleModel
+ cfg *config.Config
}
-func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
+func NewStripeHandler(users *models.UserModel, alerts *models.AlertRuleModel, cfg *config.Config) *StripeHandler {
stripe.Key = cfg.StripeSecretKey
- return &StripeHandler{users: users, cfg: cfg}
+ return &StripeHandler{users: users, alerts: alerts, cfg: cfg}
}
-func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier) (string, error) {
+func (h *StripeHandler) ensureUserFirebaseAndAlertsEnabled(ctx context.Context, localUserID string) {
+ user, err := h.users.GetByID(ctx, localUserID)
+ if err != nil || user == nil || user.FirebaseUID == "" {
+ return
+ }
+ if firebaseErr := kpfirebase.SetUserDisabled(ctx, user.FirebaseUID, false); firebaseErr != nil {
+ log.Printf("Billing access restore: firebase enable failed for user %s: %v", localUserID, firebaseErr)
+
+ return
+ }
+
+ n, alertsErr := h.alerts.EnableAllForUser(ctx, localUserID)
+ if alertsErr != nil {
+ log.Printf("Billing access restore: enable alerts failed for user %s: %v", localUserID, alertsErr)
+
+ return
+ }
+
+ log.Printf("Billing access restored: user %s, %d alert rules enabled", localUserID, n)
+}
+
+func (h *StripeHandler) restorePaidSubscriptionAccess(ctx context.Context, stripeCustomerID, status string) {
+ if stripeCustomerID == "" || (status != "active" && status != "trialing") {
+ return
+ }
+
+ u, err := h.users.GetByStripeCustomerID(ctx, stripeCustomerID)
+ if err != nil || u == nil {
+ if err != nil {
+ log.Printf("restorePaidSubscriptionAccess: lookup %s: %v", stripeCustomerID, err)
+ }
+
+ return
+ }
+
+ h.ensureUserFirebaseAndAlertsEnabled(ctx, u.ID)
+}
+
+func (h *StripeHandler) priceIDForTier(tier domain.SubscriptionTier, interval string) (string, error) {
+ if interval == "annual" {
+ switch tier {
+ case domain.TierPremium:
+ return h.cfg.StripePriceIDPremiumAnnual, nil
+ case domain.TierPro:
+ return h.cfg.StripePriceIDProAnnual, nil
+ default:
+ return "", fmt.Errorf("no Stripe price for tier %q", tier) //nolint:err113
+ }
+ }
switch tier {
case domain.TierPremium:
return h.cfg.StripePriceIDPremium, nil
@@ -46,7 +96,8 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
userID := middleware.GetUserID(r.Context())
var body struct {
- Tier string `json:"tier"`
+ Tier string `json:"tier"`
+ Interval string `json:"interval"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
@@ -56,6 +107,9 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
if body.Tier == "" {
body.Tier = "premium"
}
+ if body.Interval == "" {
+ body.Interval = "annual"
+ }
tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro {
@@ -63,7 +117,7 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
return
}
- priceID, err := h.priceIDForTier(tier)
+ priceID, err := h.priceIDForTier(tier, body.Interval)
if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
@@ -177,6 +231,8 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
+ } else {
+ h.restorePaidSubscriptionAccess(r.Context(), customerID, "active")
}
}
@@ -197,6 +253,8 @@ func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request)
return
}
+ h.ensureUserFirebaseAndAlertsEnabled(r.Context(), userID)
+
log.Printf("Free tier activated for user %s", userID)
writeJSON(w, http.StatusOK, map[string]string{
"subscription_status": "active",
@@ -209,8 +267,9 @@ func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request)
// The Firebase account is created on the frontend only after payment succeeds.
func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) {
var body struct {
- Email string `json:"email"`
- Tier string `json:"tier"`
+ Email string `json:"email"`
+ Tier string `json:"tier"`
+ Interval string `json:"interval"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
@@ -225,6 +284,9 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
if body.Tier == "" {
body.Tier = "premium"
}
+ if body.Interval == "" {
+ body.Interval = "annual"
+ }
tier := domain.SubscriptionTier(body.Tier)
if tier != domain.TierPremium && tier != domain.TierPro {
@@ -232,7 +294,7 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
return
}
- priceID, err := h.priceIDForTier(tier)
+ priceID, err := h.priceIDForTier(tier, body.Interval)
if err != nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
@@ -362,6 +424,8 @@ func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Ev
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("Failed to activate subscription: %v", err)
+ } else {
+ h.restorePaidSubscriptionAccess(r.Context(), customerID, "active")
}
}
@@ -401,8 +465,12 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
log.Printf("Failed to update subscription: %v", err)
+
+ return
}
+ h.restorePaidSubscriptionAccess(r.Context(), customerID, status)
+
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
}
diff --git a/backend/internal/models/alert_rule.go b/backend/internal/models/alert_rule.go
index e4d4278..86db8ff 100644
--- a/backend/internal/models/alert_rule.go
+++ b/backend/internal/models/alert_rule.go
@@ -140,3 +140,33 @@ func (m *AlertRuleModel) Remove(ctx context.Context, id int) (bool, error) {
}
return tag.RowsAffected() > 0, nil
}
+
+// DisableAllForUser sets enabled = false on every alert rule owned by addresses of userID.
+func (m *AlertRuleModel) DisableAllForUser(ctx context.Context, userID string) (int64, error) {
+ tag, err := m.pool.Exec(ctx,
+ `UPDATE alert_rules ar
+ SET enabled = FALSE
+ FROM addresses a
+ WHERE ar.address_id = a.id AND a.user_id = $1`,
+ userID,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return tag.RowsAffected(), nil
+}
+
+// EnableAllForUser sets enabled = true on every alert rule owned by addresses of userID.
+func (m *AlertRuleModel) EnableAllForUser(ctx context.Context, userID string) (int64, error) {
+ tag, err := m.pool.Exec(ctx,
+ `UPDATE alert_rules ar
+ SET enabled = TRUE
+ FROM addresses a
+ WHERE ar.address_id = a.id AND a.user_id = $1`,
+ userID,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return tag.RowsAffected(), nil
+}
diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go
index 9f5b4af..3529734 100644
--- a/backend/internal/models/user.go
+++ b/backend/internal/models/user.go
@@ -130,6 +130,46 @@ func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error
return scanUser(row)
}
+// GetByStripeCustomerID loads a user by their Stripe Customer ID if set.
+func (m *UserModel) GetByStripeCustomerID(ctx context.Context, stripeCustomerID string) (*domain.User, error) {
+ row := m.pool.QueryRow(ctx,
+ `SELECT `+userColumns+` FROM users WHERE stripe_customer_id = $1`,
+ stripeCustomerID,
+ )
+ return scanUser(row)
+}
+
+// ListPaidUsersWithoutActiveSubscription finds paid-tier rows whose Stripe
+// subscription is not active or trialing. Used by the periodic billing sweep.
+func (m *UserModel) ListPaidUsersWithoutActiveSubscription(ctx context.Context) ([]domain.User, error) {
+ rows, err := m.pool.Query(ctx,
+ `SELECT `+userColumns+` FROM users
+ WHERE subscription_tier IN ('premium', 'pro')
+ AND subscription_status NOT IN ('active', 'trialing')
+ AND COALESCE(trim(firebase_uid), '') <> ''`,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := []domain.User{}
+ for rows.Next() {
+ var u domain.User
+ rowErr := rows.Scan(
+ &u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
+ &u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
+ &u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
+ )
+ if rowErr != nil {
+ return nil, rowErr
+ }
+ out = append(out, u)
+ }
+
+ return out, rows.Err()
+}
+
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
_, err := m.pool.Exec(ctx,
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
diff --git a/frontend/src/api/stripe.jsx b/frontend/src/api/stripe.jsx
index 99e1cbe..43a7bf9 100644
--- a/frontend/src/api/stripe.jsx
+++ b/frontend/src/api/stripe.jsx
@@ -1,12 +1,12 @@
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
-export async function createCheckoutSession(tier = "premium") {
+export async function createCheckoutSession(tier = "premium", interval = "annual") {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
- body: JSON.stringify({ tier }),
+ body: JSON.stringify({ tier, interval }),
});
if (!res.ok) {
const data = await res.json();
diff --git a/frontend/src/components/TierPicker.css b/frontend/src/components/TierPicker.css
index c783237..5a63b8b 100644
--- a/frontend/src/components/TierPicker.css
+++ b/frontend/src/components/TierPicker.css
@@ -1,4 +1,45 @@
.tier-picker {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.tier-picker__toggle {
+ display: flex;
+ justify-content: center;
+ gap: 0;
+}
+
+.tier-picker__toggle-btn {
+ padding: 0.75rem 1.5rem;
+ border: 1px solid var(--color-border);
+ background: transparent;
+ color: var(--color-text-dimmed);
+ cursor: pointer;
+ transition: all 0.2s;
+ font-weight: 500;
+}
+
+.tier-picker__toggle-btn:first-child {
+ border-radius: 8px 0 0 8px;
+}
+
+.tier-picker__toggle-btn:last-child {
+ border-radius: 0 8px 8px 0;
+ border-left: none;
+}
+
+.tier-picker__toggle-btn--active {
+ background: var(--color-primary);
+ color: white;
+ border-color: var(--color-primary);
+}
+
+.tier-picker__toggle-btn:last-child.tier-picker__toggle-btn--active {
+ border-left: 1px solid var(--color-primary);
+}
+
+.tier-picker__cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
@@ -112,7 +153,7 @@
}
@media (max-width: 768px) {
- .tier-picker {
+ .tier-picker__cards {
grid-template-columns: 1fr;
max-width: 400px;
margin: 0 auto;
diff --git a/frontend/src/components/TierPicker.jsx b/frontend/src/components/TierPicker.jsx
index db18200..e763fbb 100644
--- a/frontend/src/components/TierPicker.jsx
+++ b/frontend/src/components/TierPicker.jsx
@@ -1,11 +1,12 @@
+import { useState } from "react";
import "./TierPicker.css";
const TIERS = [
{
id: "free",
name: "Trial Monitoring",
- price: "$0",
- period: "",
+ price: { monthly: "$0", annual: "$0" },
+ period: { monthly: "", annual: "" },
features: [
"Monitor 1 blockchain address 24/7",
"Configure alerts to fire on trigger events",
@@ -16,8 +17,8 @@ const TIERS = [
{
id: "premium",
name: "Premium Monitoring",
- price: "$1.99",
- period: "/month",
+ price: { monthly: "$8.78", annual: "$94.78" },
+ period: { monthly: "/month", annual: "/year" },
features: [
"Monitor 3 blockchain addresses",
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
@@ -31,8 +32,8 @@ const TIERS = [
{
id: "pro",
name: "Professional Monitoring",
- price: "$11.99",
- period: "/month",
+ price: { monthly: "$16.78", annual: "$181.78" },
+ period: { monthly: "/month", annual: "/year" },
features: [
"Monitor unlimited blockchain addresses",
"Configure unlimited alert rules to fire on unlimited events on any address",
@@ -47,43 +48,66 @@ const TIERS = [
];
export default function TierPicker({ onSelect, selectedTier }) {
+ const [isAnnual, setIsAnnual] = useState(true);
+
return (
- {TIERS.map((tier) => (
-
+
+
+
+ {TIERS.map((tier) => (
+
- {selectedTier === tier.id ? "Selected" : "Select"}
-
-
- ))}
+ {tier.highlighted && (
+
Most Popular
+ )}
+
{tier.name}
+
+
+ {isAnnual ? tier.price.annual : tier.price.monthly}
+
+ {(isAnnual ? tier.period.annual : tier.period.monthly) && (
+
+ {isAnnual ? tier.period.annual : tier.period.monthly}
+
+ )}
+
+
+ {tier.features.map((f) => (
+ -
+ ✓ {f}
+
+ ))}
+ {tier.disabledFeatures.map((f) => (
+ -
+ — {f}
+
+ ))}
+
+
onSelect(tier.id, isAnnual ? "annual" : "monthly")}
+ >
+ {selectedTier === tier.id ? "Selected" : "Select"}
+
+
+ ))}
+
);
}
diff --git a/frontend/src/pages/subscribe/Subscribe.jsx b/frontend/src/pages/subscribe/Subscribe.jsx
index 38e201c..0ac5982 100644
--- a/frontend/src/pages/subscribe/Subscribe.jsx
+++ b/frontend/src/pages/subscribe/Subscribe.jsx
@@ -39,6 +39,7 @@ export default function Subscribe() {
password: "",
confirmPassword: "",
selectedTier: null,
+ billingInterval: "annual",
});
const [error, setError] = useState(
searchParams.get("payment") === "cancelled"
@@ -126,7 +127,7 @@ export default function Subscribe() {
await refreshAccount();
navigate("/addresses", { replace: true });
} else {
- const { url } = await createCheckoutSession(data.selectedTier);
+ const { url } = await createCheckoutSession(data.selectedTier, data.billingInterval);
window.location.href = url;
}
} catch (err) {
@@ -245,7 +246,10 @@ export default function Subscribe() {
Select the plan that works best for you. You can upgrade anytime.
set("selectedTier", tier)}
+ onSelect={(tier, interval) => {
+ set("selectedTier", tier);
+ set("billingInterval", interval);
+ }}
selectedTier={data.selectedTier}
/>
>