Stripe integration

This commit is contained in:
KS Jannette
2026-03-04 15:08:37 -05:00
parent 3ee1598478
commit 9ad414313b
13 changed files with 537 additions and 70 deletions

View File

@@ -61,8 +61,15 @@ func main() {
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel)
// authAndSub chains authentication + subscription check for protected routes.
authAndSub := func(h http.Handler) http.Handler {
return authenticate(requireSub(h))
}
mux := http.NewServeMux() mux := http.NewServeMux()
b := cfg.APIBasePath // e.g. "/v1" b := cfg.APIBasePath // e.g. "/v1"
@@ -71,45 +78,54 @@ func main() {
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus) mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
// Authenticated routes — addresses // Stripe webhook (public — called by Stripe, not authenticated)
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
// Stripe routes (auth required, NO subscription required)
mux.Handle("POST "+b+"/stripe/create-checkout-session",
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
mux.Handle("GET "+b+"/stripe/subscription-status",
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
// Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",
authenticate(http.HandlerFunc(addressHandler.Create))) authAndSub(http.HandlerFunc(addressHandler.Create)))
mux.Handle("GET "+b+"/addresses", mux.Handle("GET "+b+"/addresses",
authenticate(http.HandlerFunc(addressHandler.List))) authAndSub(http.HandlerFunc(addressHandler.List)))
mux.Handle("DELETE "+b+"/addresses/{addressId}", mux.Handle("DELETE "+b+"/addresses/{addressId}",
authenticate(http.HandlerFunc(addressHandler.Remove))) authAndSub(http.HandlerFunc(addressHandler.Remove)))
mux.Handle("PATCH "+b+"/addresses/{addressId}", mux.Handle("PATCH "+b+"/addresses/{addressId}",
authenticate(http.HandlerFunc(addressHandler.UpdateLabel))) authAndSub(http.HandlerFunc(addressHandler.UpdateLabel)))
// Authenticated routes for alert rules // Authenticated + subscribed routes alert rules
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
authenticate(http.HandlerFunc(alertRuleHandler.Create))) authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
mux.Handle("PATCH "+b+"/alerts/{alertId}", mux.Handle("PATCH "+b+"/alerts/{alertId}",
authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
mux.Handle("DELETE "+b+"/alerts/{alertId}", mux.Handle("DELETE "+b+"/alerts/{alertId}",
authenticate(http.HandlerFunc(alertRuleHandler.Remove))) authAndSub(http.HandlerFunc(alertRuleHandler.Remove)))
// Authenticated routes — alert events // Authenticated + subscribed routes — alert events
mux.Handle("GET "+b+"/alert-events", mux.Handle("GET "+b+"/alert-events",
authenticate(http.HandlerFunc(alertEventHandler.List))) authAndSub(http.HandlerFunc(alertEventHandler.List)))
// Authenticated routes — notification config // Authenticated + subscribed routes — notification config
mux.Handle("GET "+b+"/notification-config", mux.Handle("GET "+b+"/notification-config",
authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("PUT "+b+"/notification-config", mux.Handle("PUT "+b+"/notification-config",
authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
mux.Handle("DELETE "+b+"/notification-config", mux.Handle("DELETE "+b+"/notification-config",
authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
mux.Handle("POST "+b+"/notification-config/test", mux.Handle("POST "+b+"/notification-config/test",
authenticate(http.HandlerFunc(notifConfigHandler.TestChannels))) authAndSub(http.HandlerFunc(notifConfigHandler.TestChannels)))
// Authenticated routes — email digest // Authenticated + subscribed routes — email digest
mux.Handle("POST "+b+"/email/setup", mux.Handle("POST "+b+"/email/setup",
authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail))) authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
mux.Handle("POST "+b+"/email/digest", mux.Handle("POST "+b+"/email/digest",
authenticate(http.HandlerFunc(emailDigestHandler.SendDigest))) authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
handler := corsMiddleware(mux) handler := corsMiddleware(mux)

View File

@@ -44,6 +44,7 @@ require (
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/resend/resend-go/v3 v3.1.1 // indirect github.com/resend/resend-go/v3 v3.1.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect

View File

@@ -101,6 +101,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=

View File

@@ -0,0 +1,8 @@
-- Migration 007: Add Stripe subscription fields to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(50) DEFAULT 'none';
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_created_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id);

View File

@@ -11,6 +11,10 @@ CREATE TABLE users (
firebase_uid VARCHAR(128) NOT NULL UNIQUE, firebase_uid VARCHAR(128) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL,
display_name VARCHAR(255), display_name VARCHAR(255),
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
subscription_status VARCHAR(50) DEFAULT 'none',
subscription_created_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() updated_at TIMESTAMP DEFAULT NOW()
); );
@@ -73,6 +77,7 @@ CREATE TABLE user_notification_configs (
-- Create indexes for common queries -- Create indexes for common queries
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid); CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
CREATE INDEX idx_users_stripe_customer_id ON users(stripe_customer_id);
CREATE INDEX idx_addresses_user_id ON addresses(user_id); CREATE INDEX idx_addresses_user_id ON addresses(user_id);
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id); CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled); CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);

View File

@@ -32,6 +32,11 @@ type Config struct {
ResendAPIKey string ResendAPIKey string
EmailFrom string EmailFrom string
DigestIntervalHours int DigestIntervalHours int
StripeSecretKey string
StripeWebhookSecret string
StripePriceID string
StripePublishableKey string
FrontendURL string
} }
// Load reads configuration from environment variables and returns a Config. // Load reads configuration from environment variables and returns a Config.
@@ -52,6 +57,11 @@ func Load() (*Config, error) {
ResendAPIKey: os.Getenv("RESEND_API_KEY"), ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"), EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours), DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
StripePriceID: os.Getenv("STRIPE_PRICE_ID"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
} }
if cfg.PollIntervalMS < minPollIntervalMS { if cfg.PollIntervalMS < minPollIntervalMS {

View File

@@ -7,6 +7,10 @@ type User struct {
FirebaseUID string `json:"-"` FirebaseUID string `json:"-"`
Email string `json:"email"` Email string `json:"email"`
DisplayName *string `json:"display_name"` //nolint:tagliatelle DisplayName *string `json:"display_name"` //nolint:tagliatelle
StripeCustomerID *string `json:"-"`
StripeSubscriptionID *string `json:"-"`
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
} }

View File

@@ -0,0 +1,199 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/stripe/stripe-go/v82"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"github.com/stripe/stripe-go/v82/webhook"
"github.com/kjannette/koin-ping/backend-go/internal/config"
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
"github.com/kjannette/koin-ping/backend-go/internal/models"
)
const webhookMaxBodyBytes = 65536
type StripeHandler struct {
users *models.UserModel
cfg *config.Config
}
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
stripe.Key = cfg.StripeSecretKey
return &StripeHandler{users: users, cfg: cfg}
}
// CreateCheckoutSession creates a Stripe Checkout session for the monthly subscription.
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Failed to get user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
return
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(h.cfg.StripePriceID),
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(h.cfg.FrontendURL + "/onboarding?payment=success&session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripe.String(h.cfg.FrontendURL + "/onboarding?payment=cancelled"),
ClientReferenceID: stripe.String(userID),
CustomerEmail: stripe.String(user.Email),
}
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
params.Customer = user.StripeCustomerID
params.CustomerEmail = nil
}
s, err := checkoutsession.New(params)
if err != nil {
log.Printf("Failed to create Stripe checkout session: %v", err)
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
}
// GetSubscriptionStatus returns the current user's subscription state.
func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
user, err := h.users.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("Failed to get user %s: %v", userID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"subscription_status": user.SubscriptionStatus,
"subscription_created_at": user.SubscriptionCreatedAt,
})
}
// HandleWebhook processes incoming Stripe webhook events.
// This endpoint must NOT require authentication (Stripe calls it directly).
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
if err != nil {
log.Printf("Error reading webhook body: %v", err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
sig := r.Header.Get("Stripe-Signature")
event, err := webhook.ConstructEvent(payload, sig, h.cfg.StripeWebhookSecret)
if err != nil {
log.Printf("Webhook signature verification failed: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
switch event.Type {
case "checkout.session.completed":
h.handleCheckoutCompleted(r, event)
case "customer.subscription.updated":
h.handleSubscriptionUpdated(r, event)
case "customer.subscription.deleted":
h.handleSubscriptionDeleted(r, event)
default:
log.Printf("Unhandled Stripe event type: %s", event.Type)
}
w.WriteHeader(http.StatusOK)
}
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
var session stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
log.Printf("Error parsing checkout session: %v", err)
return
}
userID := session.ClientReferenceID
if userID == "" {
log.Println("Checkout session missing client_reference_id")
return
}
customerID := ""
if session.Customer != nil {
customerID = session.Customer.ID
}
subscriptionID := ""
if session.Subscription != nil {
subscriptionID = session.Subscription.ID
}
if customerID != "" {
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
log.Printf("Failed to save Stripe customer ID: %v", err)
}
}
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
log.Printf("Failed to activate subscription: %v", err)
}
}
log.Printf("Checkout completed for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
}
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
log.Printf("Error parsing subscription update: %v", err)
return
}
customerID := ""
if sub.Customer != nil {
customerID = sub.Customer.ID
}
if customerID == "" {
return
}
status := string(sub.Status)
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status); err != nil {
log.Printf("Failed to update subscription status: %v", err)
}
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID)
}
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
log.Printf("Error parsing subscription deletion: %v", err)
return
}
customerID := ""
if sub.Customer != nil {
customerID = sub.Customer.ID
}
if customerID == "" {
return
}
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, "canceled"); err != nil {
log.Printf("Failed to mark subscription canceled: %v", err)
}
log.Printf("Subscription canceled for customer %s", customerID)
}

View File

@@ -100,6 +100,43 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
} }
} }
// RequireSubscription blocks requests from users without an active subscription.
// Must be applied after Authenticate.
func RequireSubscription(userModel *models.UserModel) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := GetUserID(r.Context())
if userID == "" {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Authentication required",
})
return
}
user, err := userModel.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("RequireSubscription: failed to load user %s: %v", userID, err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "Failed to verify subscription",
})
return
}
if user.SubscriptionStatus != "active" && user.SubscriptionStatus != "trialing" {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "SUBSCRIPTION_REQUIRED",
Message: "An active subscription is required to use this feature",
})
return
}
next.ServeHTTP(w, r)
})
}
}
func GetUserID(ctx context.Context) string { func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok { if v, ok := ctx.Value(UserIDKey).(string); ok {
return v return v

View File

@@ -17,37 +17,73 @@ func NewUserModel(pool *pgxpool.Pool) *UserModel {
return &UserModel{pool: pool} return &UserModel{pool: pool}
} }
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID, const userColumns = `id, firebase_uid, email, display_name,
// creating one if it doesn't exist yet. On conflict (returning user) the stripe_customer_id, stripe_subscription_id, subscription_status,
// updated_at timestamp is refreshed. subscription_created_at, created_at, updated_at`
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
func scanUser(row pgx.Row) (*domain.User, error) {
var u domain.User var u domain.User
err := m.pool.QueryRow(ctx, err := row.Scan(
`INSERT INTO users (firebase_uid, email) &u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
VALUES ($1, $2) &u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW() &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
RETURNING id, firebase_uid, email, display_name, created_at, updated_at`, )
firebaseUID, email,
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil //nolint:nilnil
}
return nil, err return nil, err
} }
return &u, nil return &u, nil
} }
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
// creating one if it doesn't exist yet. On conflict (returning user) the
// updated_at timestamp is refreshed.
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`INSERT INTO users (firebase_uid, email)
VALUES ($1, $2)
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
RETURNING `+userColumns,
firebaseUID, email,
)
return scanUser(row)
}
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) { func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
var u domain.User row := m.pool.QueryRow(ctx,
err := m.pool.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE id = $1`, id,
`SELECT id, firebase_uid, email, display_name, created_at, updated_at )
FROM users return scanUser(row)
WHERE id = $1`,
id,
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
} }
return nil, 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`,
userID, stripeCustomerID,
)
return err
} }
return &u, nil
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string) error {
_, err := m.pool.Exec(ctx,
`UPDATE users
SET stripe_subscription_id = $2,
subscription_status = $3,
subscription_created_at = COALESCE(subscription_created_at, NOW()),
updated_at = NOW()
WHERE stripe_customer_id = $1`,
stripeCustomerID, subscriptionID, status,
)
return err
}
func (m *UserModel) UpdateSubscriptionStatus(ctx context.Context, stripeCustomerID, status string) error {
_, err := m.pool.Exec(ctx,
`UPDATE users SET subscription_status = $2, updated_at = NOW()
WHERE stripe_customer_id = $1`,
stripeCustomerID, status,
)
return err
} }

View File

@@ -0,0 +1,27 @@
import { getAuthHeaders } from "./authHeaders";
import { API_BASE } from "./config";
export async function createCheckoutSession() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
method: "POST",
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to create checkout session");
}
return res.json();
}
export async function getSubscriptionStatus() {
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
headers,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || "Failed to get subscription status");
}
return res.json();
}

View File

@@ -183,3 +183,56 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Subscribe card (Step 2) */
.subscribe-card {
background-color: var(--color-bg-card, #1a1a2e);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: 1.5rem;
text-align: center;
margin-bottom: 1rem;
}
.subscribe-card__price {
margin-bottom: 1.25rem;
}
.subscribe-card__amount {
font-size: 2.5rem;
font-weight: 700;
color: white;
}
.subscribe-card__period {
font-size: 1rem;
color: #888;
margin-left: 2px;
}
.subscribe-card__features {
list-style: none;
padding: 0;
margin: 0 0 1.25rem;
text-align: left;
}
.subscribe-card__features li {
padding: 0.35rem 0;
color: #ccc;
font-size: 0.9rem;
}
.subscribe-card__features li::before {
content: "\2713";
color: var(--color-primary);
margin-right: 0.6rem;
font-weight: bold;
}
.subscribe-card__commitment {
color: #999;
font-size: 0.8rem;
margin: 0;
font-style: italic;
}

View File

@@ -1,11 +1,11 @@
/** /**
* Onboarding Wizard * Onboarding Wizard
* *
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done * 6-step guided flow: Create Account -> Subscribe -> Add Wallet -> Alert Rules -> Notifications -> Done
*/ */
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { createAddress, getAddresses } from "../api/addresses"; import { createAddress, getAddresses } from "../api/addresses";
import { createAlert } from "../api/alerts"; import { createAlert } from "../api/alerts";
@@ -13,12 +13,14 @@ import {
updateNotificationConfig, updateNotificationConfig,
testNotificationChannels, testNotificationChannels,
} from "../api/notificationConfig"; } from "../api/notificationConfig";
import { createCheckoutSession, getSubscriptionStatus } from "../api/stripe";
import Input from "../components/Input"; import Input from "../components/Input";
import Button from "../components/Button"; import Button from "../components/Button";
import "./Onboarding.css"; import "./Onboarding.css";
const STEPS = [ const STEPS = [
"Create Account", "Create Account",
"Subscribe",
"Add Wallet", "Add Wallet",
"Alert Rules", "Alert Rules",
"Notifications", "Notifications",
@@ -28,6 +30,7 @@ const STEPS = [
export default function Onboarding() { export default function Onboarding() {
const { currentUser, signup } = useAuth(); const { currentUser, signup } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -71,6 +74,20 @@ export default function Onboarding() {
.catch(() => {}); .catch(() => {});
}, [currentUser, navigate]); }, [currentUser, navigate]);
// Handle Stripe redirect back from checkout
useEffect(() => {
if (!currentUser) return;
const payment = searchParams.get("payment");
if (payment === "success") {
setSearchParams({}, { replace: true });
setStep(3);
} else if (payment === "cancelled") {
setSearchParams({}, { replace: true });
setStep(2);
setError("Payment was cancelled. Please subscribe to continue.");
}
}, [currentUser, searchParams, setSearchParams]);
// ── Step handlers ───────────────────────────────────────────────────────── // ── Step handlers ─────────────────────────────────────────────────────────
async function handleStep1() { async function handleStep1() {
@@ -110,7 +127,24 @@ export default function Onboarding() {
setStep(2); setStep(2);
} }
async function handleStep2() { async function handleStep2Subscribe() {
setError("");
try {
setLoading(true);
const status = await getSubscriptionStatus();
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
setStep(3);
return;
}
const { url } = await createCheckoutSession();
window.location.href = url;
} catch (err) {
setError(err.message);
setLoading(false);
}
}
async function handleStep3() {
setError(""); setError("");
if (!data.walletAddress) { if (!data.walletAddress) {
setError("Please enter a wallet address"); setError("Please enter a wallet address");
@@ -127,7 +161,7 @@ export default function Onboarding() {
label: data.walletLabel || undefined, label: data.walletLabel || undefined,
}); });
set("createdAddressId", created.id); set("createdAddressId", created.id);
setStep(3); setStep(4);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -135,7 +169,7 @@ export default function Onboarding() {
} }
} }
async function handleStep3() { async function handleStep4() {
setError(""); setError("");
const rules = []; const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" }); if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
@@ -156,7 +190,7 @@ export default function Onboarding() {
} }
if (rules.length === 0) { if (rules.length === 0) {
setStep(4); setStep(5);
return; return;
} }
@@ -168,7 +202,7 @@ export default function Onboarding() {
created.push(result); created.push(result);
} }
set("alertsCreated", created); set("alertsCreated", created);
setStep(4); setStep(5);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -176,12 +210,12 @@ export default function Onboarding() {
} }
} }
async function handleStep4() { async function handleStep5() {
setError(""); setError("");
const hasAny = const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail; data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) { if (!hasAny) {
setStep(5); setStep(6);
return; return;
} }
try { try {
@@ -193,7 +227,7 @@ export default function Onboarding() {
email: data.notificationEmail || undefined, email: data.notificationEmail || undefined,
}); });
set("notificationConfigured", true); set("notificationConfigured", true);
setStep(5); setStep(6);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -297,6 +331,33 @@ export default function Onboarding() {
} }
function Step2() { function Step2() {
return (
<>
<h2 className="mb-sm">Subscribe to Koin Ping</h2>
<p className="onboarding__subtitle">
A subscription is required to use Koin Ping.
</p>
<div className="subscribe-card">
<div className="subscribe-card__price">
<span className="subscribe-card__amount">$1.99</span>
<span className="subscribe-card__period">/month</span>
</div>
<ul className="subscribe-card__features">
<li>Unlimited wallet monitoring</li>
<li>Real-time alert notifications</li>
<li>Discord, Slack, Telegram & email alerts</li>
<li>Email digest reports</li>
</ul>
<p className="subscribe-card__commitment">
6-month minimum commitment
</p>
</div>
</>
);
}
function Step3() {
return ( return (
<> <>
<h2 className="mb-sm">Add a wallet address</h2> <h2 className="mb-sm">Add a wallet address</h2>
@@ -322,7 +383,7 @@ export default function Onboarding() {
); );
} }
function Step3() { function Step4() {
return ( return (
<> <>
<h2 className="mb-sm">Configure alert rules</h2> <h2 className="mb-sm">Configure alert rules</h2>
@@ -382,7 +443,7 @@ export default function Onboarding() {
); );
} }
function Step4() { function Step5() {
return ( return (
<> <>
<h2 className="mb-sm">Set up notifications</h2> <h2 className="mb-sm">Set up notifications</h2>
@@ -447,7 +508,7 @@ export default function Onboarding() {
); );
} }
function Step5() { function Step6() {
const alertCount = data.alertsCreated.length; const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured; const hasNotif = data.notificationConfigured;
@@ -541,17 +602,18 @@ export default function Onboarding() {
// ── Footer navigation ───────────────────────────────────────────────────── // ── Footer navigation ─────────────────────────────────────────────────────
function Footer() { function Footer() {
if (step === 5) return null; if (step === 6) return null;
const canSkip = step === 3 || step === 4; const canSkip = step === 4 || step === 5;
const canBack = step > 1; const canBack = step > 1 && step !== 2;
async function handleNext() { async function handleNext() {
setSkipWarning(""); setSkipWarning("");
if (step === 1) await handleStep1(); if (step === 1) await handleStep1();
else if (step === 2) await handleStep2(); else if (step === 2) await handleStep2Subscribe();
else if (step === 3) await handleStep3(); else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4(); else if (step === 4) await handleStep4();
else if (step === 5) await handleStep5();
} }
function handleSkip() { function handleSkip() {
@@ -566,6 +628,12 @@ export default function Onboarding() {
setStep((s) => s - 1); setStep((s) => s - 1);
} }
const nextLabel = step === 2
? "Subscribe — $1.99/mo"
: step === 5
? "Finish"
: "Next →";
return ( return (
<div className="onboarding__footer"> <div className="onboarding__footer">
<div> <div>
@@ -595,7 +663,7 @@ export default function Onboarding() {
disabled={loading} disabled={loading}
className="text-bold" className="text-bold"
> >
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"} {loading ? "Please wait..." : nextLabel}
</Button> </Button>
</div> </div>
</div> </div>
@@ -610,6 +678,7 @@ export default function Onboarding() {
3: <Step3 />, 3: <Step3 />,
4: <Step4 />, 4: <Step4 />,
5: <Step5 />, 5: <Step5 />,
6: <Step6 />,
}; };
return ( return (