From 9ad414313b2200b77ef4c86f1891d6fda5904d30 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Wed, 4 Mar 2026 15:08:37 -0500 Subject: [PATCH 1/2] Stripe integration --- backend-go/cmd/api/main.go | 56 +++-- backend-go/go.mod | 1 + backend-go/go.sum | 2 + .../migrations/007_add_stripe_to_users.sql | 8 + backend-go/infra/schema.sql | 5 + backend-go/internal/config/config.go | 12 +- backend-go/internal/domain/types.go | 16 +- backend-go/internal/handlers/stripe.go | 199 ++++++++++++++++++ backend-go/internal/middleware/auth.go | 37 ++++ backend-go/internal/models/user.go | 86 +++++--- frontend/src/api/stripe.jsx | 27 +++ frontend/src/pages/Onboarding.css | 53 +++++ frontend/src/pages/Onboarding.jsx | 105 +++++++-- 13 files changed, 537 insertions(+), 70 deletions(-) create mode 100644 backend-go/infra/migrations/007_add_stripe_to_users.sql create mode 100644 backend-go/internal/handlers/stripe.go create mode 100644 frontend/src/api/stripe.jsx diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go index 8c6c597..1fd7d98 100644 --- a/backend-go/cmd/api/main.go +++ b/backend-go/cmd/api/main.go @@ -61,8 +61,15 @@ func main() { notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) statusHandler := handlers.NewStatusHandler(checkpointModel) + stripeHandler := handlers.NewStripeHandler(userModel, cfg) 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() b := cfg.APIBasePath // e.g. "/v1" @@ -71,45 +78,54 @@ func main() { mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) 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", - authenticate(http.HandlerFunc(addressHandler.Create))) + authAndSub(http.HandlerFunc(addressHandler.Create))) mux.Handle("GET "+b+"/addresses", - authenticate(http.HandlerFunc(addressHandler.List))) + authAndSub(http.HandlerFunc(addressHandler.List))) mux.Handle("DELETE "+b+"/addresses/{addressId}", - authenticate(http.HandlerFunc(addressHandler.Remove))) + authAndSub(http.HandlerFunc(addressHandler.Remove))) 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", - authenticate(http.HandlerFunc(alertRuleHandler.Create))) + authAndSub(http.HandlerFunc(alertRuleHandler.Create))) mux.Handle("GET "+b+"/addresses/{addressId}/alerts", - authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) + authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress))) mux.Handle("PATCH "+b+"/alerts/{alertId}", - authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) + authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus))) 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", - 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", - authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) + authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig))) mux.Handle("PUT "+b+"/notification-config", - authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) + authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig))) mux.Handle("DELETE "+b+"/notification-config", - authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) + authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig))) 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", - authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail))) + authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail))) mux.Handle("POST "+b+"/email/digest", - authenticate(http.HandlerFunc(emailDigestHandler.SendDigest))) + authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest))) handler := corsMiddleware(mux) diff --git a/backend-go/go.mod b/backend-go/go.mod index 805ae3e..f464778 100644 --- a/backend-go/go.mod +++ b/backend-go/go.mod @@ -44,6 +44,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/resend/resend-go/v3 v3.1.1 // 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/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect diff --git a/backend-go/go.sum b/backend-go/go.sum index 62a229d..7289fa9 100644 --- a/backend-go/go.sum +++ b/backend-go/go.sum @@ -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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 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= 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= diff --git a/backend-go/infra/migrations/007_add_stripe_to_users.sql b/backend-go/infra/migrations/007_add_stripe_to_users.sql new file mode 100644 index 0000000..b30cf2f --- /dev/null +++ b/backend-go/infra/migrations/007_add_stripe_to_users.sql @@ -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); diff --git a/backend-go/infra/schema.sql b/backend-go/infra/schema.sql index 9a32335..ad10f4f 100644 --- a/backend-go/infra/schema.sql +++ b/backend-go/infra/schema.sql @@ -11,6 +11,10 @@ CREATE TABLE users ( firebase_uid VARCHAR(128) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL, 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(), updated_at TIMESTAMP DEFAULT NOW() ); @@ -73,6 +77,7 @@ CREATE TABLE user_notification_configs ( -- Create indexes for common queries 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_alert_rules_address_id ON alert_rules(address_id); CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled); diff --git a/backend-go/internal/config/config.go b/backend-go/internal/config/config.go index cfa0019..9bb4d22 100644 --- a/backend-go/internal/config/config.go +++ b/backend-go/internal/config/config.go @@ -32,6 +32,11 @@ type Config struct { ResendAPIKey string EmailFrom string DigestIntervalHours int + StripeSecretKey string + StripeWebhookSecret string + StripePriceID string + StripePublishableKey string + FrontendURL string } // Load reads configuration from environment variables and returns a Config. @@ -51,7 +56,12 @@ func Load() (*Config, error) { NodeEnv: getEnv("NODE_ENV", "development"), ResendAPIKey: os.Getenv("RESEND_API_KEY"), EmailFrom: getEnv("EMAIL_FROM", "Koin Ping "), - 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 { diff --git a/backend-go/internal/domain/types.go b/backend-go/internal/domain/types.go index 2c3fb6a..88fab63 100644 --- a/backend-go/internal/domain/types.go +++ b/backend-go/internal/domain/types.go @@ -3,12 +3,16 @@ package domain import "time" type User struct { - ID string `json:"id"` - FirebaseUID string `json:"-"` - Email string `json:"email"` - DisplayName *string `json:"display_name"` //nolint:tagliatelle - CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle - UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle + ID string `json:"id"` + FirebaseUID string `json:"-"` + Email string `json:"email"` + 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 + UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle } type Address struct { diff --git a/backend-go/internal/handlers/stripe.go b/backend-go/internal/handlers/stripe.go new file mode 100644 index 0000000..555f129 --- /dev/null +++ b/backend-go/internal/handlers/stripe.go @@ -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) +} diff --git a/backend-go/internal/middleware/auth.go b/backend-go/internal/middleware/auth.go index 194ec02..b142191 100644 --- a/backend-go/internal/middleware/auth.go +++ b/backend-go/internal/middleware/auth.go @@ -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 { if v, ok := ctx.Value(UserIDKey).(string); ok { return v diff --git a/backend-go/internal/models/user.go b/backend-go/internal/models/user.go index 60db4d5..c81ab34 100644 --- a/backend-go/internal/models/user.go +++ b/backend-go/internal/models/user.go @@ -17,37 +17,73 @@ func NewUserModel(pool *pgxpool.Pool) *UserModel { return &UserModel{pool: pool} } -// 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) { - var u domain.User - err := m.pool.QueryRow(ctx, - `INSERT INTO users (firebase_uid, email) - VALUES ($1, $2) - ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW() - 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 { - return nil, err - } - return &u, nil -} +const userColumns = `id, firebase_uid, email, display_name, + stripe_customer_id, stripe_subscription_id, subscription_status, + subscription_created_at, created_at, updated_at` -func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) { +func scanUser(row pgx.Row) (*domain.User, error) { var u domain.User - err := m.pool.QueryRow(ctx, - `SELECT id, firebase_uid, email, display_name, created_at, updated_at - FROM users - WHERE id = $1`, - id, - ).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt) + err := row.Scan( + &u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, + &u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus, + &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt, + ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil, nil + return nil, nil //nolint:nilnil } return nil, err } 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) { + row := m.pool.QueryRow(ctx, + `SELECT `+userColumns+` FROM users WHERE id = $1`, id, + ) + return scanUser(row) +} + +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 +} + +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 +} diff --git a/frontend/src/api/stripe.jsx b/frontend/src/api/stripe.jsx new file mode 100644 index 0000000..9eb38e2 --- /dev/null +++ b/frontend/src/api/stripe.jsx @@ -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(); +} diff --git a/frontend/src/pages/Onboarding.css b/frontend/src/pages/Onboarding.css index 700ef81..b15e8a8 100644 --- a/frontend/src/pages/Onboarding.css +++ b/frontend/src/pages/Onboarding.css @@ -183,3 +183,56 @@ margin-bottom: 1.5rem; 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; +} diff --git a/frontend/src/pages/Onboarding.jsx b/frontend/src/pages/Onboarding.jsx index 7185068..786bcc6 100644 --- a/frontend/src/pages/Onboarding.jsx +++ b/frontend/src/pages/Onboarding.jsx @@ -1,11 +1,11 @@ /** * 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 { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { createAddress, getAddresses } from "../api/addresses"; import { createAlert } from "../api/alerts"; @@ -13,12 +13,14 @@ import { updateNotificationConfig, testNotificationChannels, } from "../api/notificationConfig"; +import { createCheckoutSession, getSubscriptionStatus } from "../api/stripe"; import Input from "../components/Input"; import Button from "../components/Button"; import "./Onboarding.css"; const STEPS = [ "Create Account", + "Subscribe", "Add Wallet", "Alert Rules", "Notifications", @@ -28,6 +30,7 @@ const STEPS = [ export default function Onboarding() { const { currentUser, signup } = useAuth(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const [step, setStep] = useState(1); const [loading, setLoading] = useState(false); @@ -71,6 +74,20 @@ export default function Onboarding() { .catch(() => {}); }, [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 ───────────────────────────────────────────────────────── async function handleStep1() { @@ -110,7 +127,24 @@ export default function Onboarding() { 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(""); if (!data.walletAddress) { setError("Please enter a wallet address"); @@ -127,7 +161,7 @@ export default function Onboarding() { label: data.walletLabel || undefined, }); set("createdAddressId", created.id); - setStep(3); + setStep(4); } catch (err) { setError(err.message); } finally { @@ -135,7 +169,7 @@ export default function Onboarding() { } } - async function handleStep3() { + async function handleStep4() { setError(""); const rules = []; if (data.alertIncomingTx) rules.push({ type: "incoming_tx" }); @@ -156,7 +190,7 @@ export default function Onboarding() { } if (rules.length === 0) { - setStep(4); + setStep(5); return; } @@ -168,7 +202,7 @@ export default function Onboarding() { created.push(result); } set("alertsCreated", created); - setStep(4); + setStep(5); } catch (err) { setError(err.message); } finally { @@ -176,12 +210,12 @@ export default function Onboarding() { } } - async function handleStep4() { + async function handleStep5() { setError(""); const hasAny = data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail; if (!hasAny) { - setStep(5); + setStep(6); return; } try { @@ -193,7 +227,7 @@ export default function Onboarding() { email: data.notificationEmail || undefined, }); set("notificationConfigured", true); - setStep(5); + setStep(6); } catch (err) { setError(err.message); } finally { @@ -297,6 +331,33 @@ export default function Onboarding() { } function Step2() { + return ( + <> +

Subscribe to Koin Ping

+

+ A subscription is required to use Koin Ping. +

+ +
+
+ $1.99 + /month +
+
    +
  • Unlimited wallet monitoring
  • +
  • Real-time alert notifications
  • +
  • Discord, Slack, Telegram & email alerts
  • +
  • Email digest reports
  • +
+

+ 6-month minimum commitment +

+
+ + ); + } + + function Step3() { return ( <>

Add a wallet address

@@ -322,7 +383,7 @@ export default function Onboarding() { ); } - function Step3() { + function Step4() { return ( <>

Configure alert rules

@@ -382,7 +443,7 @@ export default function Onboarding() { ); } - function Step4() { + function Step5() { return ( <>

Set up notifications

@@ -447,7 +508,7 @@ export default function Onboarding() { ); } - function Step5() { + function Step6() { const alertCount = data.alertsCreated.length; const hasNotif = data.notificationConfigured; @@ -541,17 +602,18 @@ export default function Onboarding() { // ── Footer navigation ───────────────────────────────────────────────────── function Footer() { - if (step === 5) return null; + if (step === 6) return null; - const canSkip = step === 3 || step === 4; - const canBack = step > 1; + const canSkip = step === 4 || step === 5; + const canBack = step > 1 && step !== 2; async function handleNext() { setSkipWarning(""); 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 === 4) await handleStep4(); + else if (step === 5) await handleStep5(); } function handleSkip() { @@ -566,6 +628,12 @@ export default function Onboarding() { setStep((s) => s - 1); } + const nextLabel = step === 2 + ? "Subscribe — $1.99/mo" + : step === 5 + ? "Finish" + : "Next →"; + return (
@@ -595,7 +663,7 @@ export default function Onboarding() { disabled={loading} className="text-bold" > - {loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"} + {loading ? "Please wait..." : nextLabel}
@@ -610,6 +678,7 @@ export default function Onboarding() { 3: , 4: , 5: , + 6: , }; return ( From 239ea7db85cb31f9cfc398a1e387dfa0b420aa7c Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Wed, 4 Mar 2026 21:38:36 -0500 Subject: [PATCH 2/2] addition Stripe payment integration work --- backend-go/README.md | 5 +- backend-go/internal/handlers/stripe.go | 4 +- frontend/src/App.jsx | 4 +- frontend/src/components/Input.css | 4 +- frontend/src/pages/Onboarding.jsx | 132 +++++++++---------------- frontend/src/pages/Signup.jsx | 2 +- 6 files changed, 54 insertions(+), 97 deletions(-) diff --git a/backend-go/README.md b/backend-go/README.md index 105153a..4af37f2 100644 --- a/backend-go/README.md +++ b/backend-go/README.md @@ -2,7 +2,6 @@ Start DB: brew services start postgresql@15 - From the backend-go directory, you have a few options: Option 1: Single command (both API + poller) @@ -10,9 +9,9 @@ cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all Option 2: Two separate terminals Terminal 1 (API server): -cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api +cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-go go run ./cmd/api Terminal 2 (Poller): -cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller +cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-go go run ./cmd/poller make run — Builds and runs the API server. make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed). diff --git a/backend-go/internal/handlers/stripe.go b/backend-go/internal/handlers/stripe.go index 555f129..666b204 100644 --- a/backend-go/internal/handlers/stripe.go +++ b/backend-go/internal/handlers/stripe.go @@ -46,8 +46,8 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req 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"), + SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"), + CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"), ClientReferenceID: stripe.String(userID), CustomerEmail: stripe.String(user.Email), } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c94c658..60dd37a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,7 +16,7 @@ export default function App() { } /> } /> - } /> + } /> } /> ); @@ -30,7 +30,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/components/Input.css b/frontend/src/components/Input.css index 9b1452b..a12be6f 100644 --- a/frontend/src/components/Input.css +++ b/frontend/src/components/Input.css @@ -1,6 +1,6 @@ .input__label { display: block; - margin-bottom: 0.4rem; + margin: 0.8rem 0rem 0.4rem 0rem; font-size: 0.9rem; } @@ -18,4 +18,4 @@ outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Onboarding.jsx b/frontend/src/pages/Onboarding.jsx index 786bcc6..022e2b0 100644 --- a/frontend/src/pages/Onboarding.jsx +++ b/frontend/src/pages/Onboarding.jsx @@ -1,7 +1,9 @@ /** - * Onboarding Wizard + * Subscribe / Onboarding Wizard * - * 6-step guided flow: Create Account -> Subscribe -> Add Wallet -> Alert Rules -> Notifications -> Done + * 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done + * After account creation, user is redirected to Stripe Checkout for payment. + * On successful payment, they return here at step 2 (Add Wallet). */ import { useState, useEffect } from "react"; @@ -20,7 +22,6 @@ import "./Onboarding.css"; const STEPS = [ "Create Account", - "Subscribe", "Add Wallet", "Alert Rules", "Notifications", @@ -80,11 +81,11 @@ export default function Onboarding() { const payment = searchParams.get("payment"); if (payment === "success") { setSearchParams({}, { replace: true }); - setStep(3); + setStep(2); } else if (payment === "cancelled") { setSearchParams({}, { replace: true }); - setStep(2); - setError("Payment was cancelled. Please subscribe to continue."); + setStep(1); + setError("Payment was cancelled. Please try again."); } }, [currentUser, searchParams, setSearchParams]); @@ -104,47 +105,33 @@ export default function Onboarding() { setError("Password must be at least 6 characters"); return; } - if (!currentUser) { - try { - setLoading(true); - await signup(data.email, data.password); - } catch (err) { - if (err.code === "auth/email-already-in-use") { - setError("Email already in use. Try logging in instead."); - } else if (err.code === "auth/invalid-email") { - setError("Invalid email address"); - } else if (err.code === "auth/weak-password") { - setError("Password is too weak"); - } else { - setError("Failed to create account: " + err.message); - } - setLoading(false); - return; - } finally { - setLoading(false); - } - } - setStep(2); - } - - async function handleStep2Subscribe() { - setError(""); try { setLoading(true); + if (!currentUser) { + await signup(data.email, data.password); + } const status = await getSubscriptionStatus(); if (status.subscription_status === "active" || status.subscription_status === "trialing") { - setStep(3); + setStep(2); return; } const { url } = await createCheckoutSession(); window.location.href = url; } catch (err) { - setError(err.message); + if (err.code === "auth/email-already-in-use") { + setError("Email already in use. Try logging in instead."); + } else if (err.code === "auth/invalid-email") { + setError("Invalid email address"); + } else if (err.code === "auth/weak-password") { + setError("Password is too weak"); + } else { + setError("Failed to create account: " + err.message); + } setLoading(false); } } - async function handleStep3() { + async function handleStep2() { setError(""); if (!data.walletAddress) { setError("Please enter a wallet address"); @@ -161,7 +148,7 @@ export default function Onboarding() { label: data.walletLabel || undefined, }); set("createdAddressId", created.id); - setStep(4); + setStep(3); } catch (err) { setError(err.message); } finally { @@ -169,7 +156,7 @@ export default function Onboarding() { } } - async function handleStep4() { + async function handleStep3() { setError(""); const rules = []; if (data.alertIncomingTx) rules.push({ type: "incoming_tx" }); @@ -190,7 +177,7 @@ export default function Onboarding() { } if (rules.length === 0) { - setStep(5); + setStep(4); return; } @@ -202,7 +189,7 @@ export default function Onboarding() { created.push(result); } set("alertsCreated", created); - setStep(5); + setStep(4); } catch (err) { setError(err.message); } finally { @@ -210,12 +197,12 @@ export default function Onboarding() { } } - async function handleStep5() { + async function handleStep4() { setError(""); const hasAny = data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail; if (!hasAny) { - setStep(6); + setStep(5); return; } try { @@ -227,7 +214,7 @@ export default function Onboarding() { email: data.notificationEmail || undefined, }); set("notificationConfigured", true); - setStep(6); + setStep(5); } catch (err) { setError(err.message); } finally { @@ -331,33 +318,6 @@ export default function Onboarding() { } function Step2() { - return ( - <> -

Subscribe to Koin Ping

-

- A subscription is required to use Koin Ping. -

- -
-
- $1.99 - /month -
-
    -
  • Unlimited wallet monitoring
  • -
  • Real-time alert notifications
  • -
  • Discord, Slack, Telegram & email alerts
  • -
  • Email digest reports
  • -
-

- 6-month minimum commitment -

-
- - ); - } - - function Step3() { return ( <>

Add a wallet address

@@ -383,7 +343,7 @@ export default function Onboarding() { ); } - function Step4() { + function Step3() { return ( <>

Configure alert rules

@@ -443,7 +403,7 @@ export default function Onboarding() { ); } - function Step5() { + function Step4() { return ( <>

Set up notifications

@@ -508,7 +468,7 @@ export default function Onboarding() { ); } - function Step6() { + function Step5() { const alertCount = data.alertsCreated.length; const hasNotif = data.notificationConfigured; @@ -602,18 +562,17 @@ export default function Onboarding() { // ── Footer navigation ───────────────────────────────────────────────────── function Footer() { - if (step === 6) return null; + if (step === 5) return null; - const canSkip = step === 4 || step === 5; - const canBack = step > 1 && step !== 2; + const canSkip = step === 3 || step === 4; + const canBack = step > 2; async function handleNext() { setSkipWarning(""); if (step === 1) await handleStep1(); - else if (step === 2) await handleStep2Subscribe(); + else if (step === 2) await handleStep2(); else if (step === 3) await handleStep3(); else if (step === 4) await handleStep4(); - else if (step === 5) await handleStep5(); } function handleSkip() { @@ -628,9 +587,9 @@ export default function Onboarding() { setStep((s) => s - 1); } - const nextLabel = step === 2 - ? "Subscribe — $1.99/mo" - : step === 5 + const nextLabel = step === 1 + ? "Create Account & Subscribe" + : step === 4 ? "Finish" : "Next →"; @@ -673,12 +632,11 @@ export default function Onboarding() { // ── Render ──────────────────────────────────────────────────────────────── const stepContent = { - 1: , - 2: , - 3: , - 4: , - 5: , - 6: , + 1: Step1(), + 2: Step2(), + 3: Step3(), + 4: Step4(), + 5: Step5(), }; return ( @@ -686,7 +644,7 @@ export default function Onboarding() {

Koin Ping

- + {ProgressBar()} {error && (
{error}
@@ -698,7 +656,7 @@ export default function Onboarding() {
{stepContent[step]} -
+ {Footer()}
{step === 1 && ( diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx index bbdd78e..1945c2c 100644 --- a/frontend/src/pages/Signup.jsx +++ b/frontend/src/pages/Signup.jsx @@ -1,5 +1,5 @@ import { Navigate } from "react-router-dom"; export default function Signup() { - return ; + return ; }