Compare commits
6 Commits
refactor-c
...
stripe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
239ea7db85 | ||
|
|
9ad414313b | ||
|
|
3ee1598478 | ||
|
|
91336130b9 | ||
|
|
e4e859d051 | ||
|
|
6f6a2c6d18 |
@@ -2,7 +2,6 @@ Start DB:
|
|||||||
|
|
||||||
brew services start postgresql@15
|
brew services start postgresql@15
|
||||||
|
|
||||||
|
|
||||||
From the backend-go directory, you have a few options:
|
From the backend-go directory, you have a few options:
|
||||||
|
|
||||||
Option 1: Single command (both API + poller)
|
Option 1: Single command (both API + poller)
|
||||||
@@ -10,6 +9,12 @@ cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
|
|||||||
|
|
||||||
Option 2: Two separate terminals
|
Option 2: Two separate terminals
|
||||||
Terminal 1 (API server):
|
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):
|
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).
|
||||||
|
make poller — Builds and runs the poller.
|
||||||
|
make poller-dev — Runs the poller with auto-reload.
|
||||||
|
make dev-all — Runs both the API and poller concurrently.
|
||||||
@@ -44,6 +44,7 @@ func main() {
|
|||||||
|
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
|
userModel := models.NewUserModel(pool)
|
||||||
addressModel := models.NewAddressModel(pool)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
@@ -60,6 +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)
|
||||||
|
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"
|
||||||
@@ -68,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",
|
||||||
middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
|
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||||
mux.Handle("GET "+b+"/addresses",
|
mux.Handle("GET "+b+"/addresses",
|
||||||
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
authAndSub(http.HandlerFunc(addressHandler.List)))
|
||||||
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||||
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
authAndSub(http.HandlerFunc(addressHandler.Remove)))
|
||||||
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
||||||
middleware.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",
|
||||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
|
||||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||||
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
||||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||||
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
||||||
middleware.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",
|
||||||
middleware.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",
|
||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||||
mux.Handle("PUT "+b+"/notification-config",
|
mux.Handle("PUT "+b+"/notification-config",
|
||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||||
mux.Handle("DELETE "+b+"/notification-config",
|
mux.Handle("DELETE "+b+"/notification-config",
|
||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||||
mux.Handle("POST "+b+"/notification-config/test",
|
mux.Handle("POST "+b+"/notification-config/test",
|
||||||
middleware.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",
|
||||||
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||||
mux.Handle("POST "+b+"/email/digest",
|
mux.Handle("POST "+b+"/email/digest",
|
||||||
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||||
|
|
||||||
handler := corsMiddleware(mux)
|
handler := corsMiddleware(mux)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
13
backend-go/infra/migrations/005_create_users_table.sql
Normal file
13
backend-go/infra/migrations/005_create_users_table.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration 005: Create users table with UUID primary key
|
||||||
|
-- This establishes a local user record for each Firebase-authenticated user.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid);
|
||||||
73
backend-go/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
73
backend-go/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
-- Migration 006: Migrate user_id columns from Firebase UID strings to user UUIDs
|
||||||
|
--
|
||||||
|
-- Prerequisites: migration 005 (users table) must be applied first.
|
||||||
|
-- This migration backfills the users table from existing data, then swaps the
|
||||||
|
-- VARCHAR user_id columns for UUID foreign keys referencing users(id).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Backfill users table from existing Firebase UIDs in addresses
|
||||||
|
INSERT INTO users (firebase_uid, email)
|
||||||
|
SELECT DISTINCT user_id, ''
|
||||||
|
FROM addresses
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
ON CONFLICT (firebase_uid) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. Backfill from notification configs (catches users with configs but no addresses)
|
||||||
|
INSERT INTO users (firebase_uid, email)
|
||||||
|
SELECT DISTINCT user_id, ''
|
||||||
|
FROM user_notification_configs
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
ON CONFLICT (firebase_uid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. Migrate addresses.user_id from VARCHAR to UUID
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE addresses ADD COLUMN user_uuid UUID;
|
||||||
|
|
||||||
|
UPDATE addresses a
|
||||||
|
SET user_uuid = u.id
|
||||||
|
FROM users u
|
||||||
|
WHERE u.firebase_uid = a.user_id;
|
||||||
|
|
||||||
|
-- Drop old constraints and column
|
||||||
|
ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_user_id_address_key;
|
||||||
|
DROP INDEX IF EXISTS idx_addresses_user_id;
|
||||||
|
ALTER TABLE addresses DROP COLUMN user_id;
|
||||||
|
|
||||||
|
-- Rename and constrain
|
||||||
|
ALTER TABLE addresses RENAME COLUMN user_uuid TO user_id;
|
||||||
|
ALTER TABLE addresses ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE addresses ADD CONSTRAINT fk_addresses_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE addresses ADD CONSTRAINT addresses_user_id_address_key
|
||||||
|
UNIQUE (user_id, address);
|
||||||
|
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. Migrate user_notification_configs.user_id from VARCHAR to UUID
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Drop the PK first (it's on user_id)
|
||||||
|
ALTER TABLE user_notification_configs DROP CONSTRAINT IF EXISTS user_notification_configs_pkey;
|
||||||
|
DROP INDEX IF EXISTS idx_notification_configs_enabled;
|
||||||
|
|
||||||
|
ALTER TABLE user_notification_configs ADD COLUMN user_uuid UUID;
|
||||||
|
|
||||||
|
UPDATE user_notification_configs nc
|
||||||
|
SET user_uuid = u.id
|
||||||
|
FROM users u
|
||||||
|
WHERE u.firebase_uid = nc.user_id;
|
||||||
|
|
||||||
|
ALTER TABLE user_notification_configs DROP COLUMN user_id;
|
||||||
|
ALTER TABLE user_notification_configs RENAME COLUMN user_uuid TO user_id;
|
||||||
|
ALTER TABLE user_notification_configs ALTER COLUMN user_id SET NOT NULL;
|
||||||
|
ALTER TABLE user_notification_configs ADD CONSTRAINT user_notification_configs_pkey
|
||||||
|
PRIMARY KEY (user_id);
|
||||||
|
ALTER TABLE user_notification_configs ADD CONSTRAINT fk_notification_configs_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
CREATE INDEX idx_notification_configs_enabled
|
||||||
|
ON user_notification_configs(notification_enabled);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
8
backend-go/infra/migrations/007_add_stripe_to_users.sql
Normal file
8
backend-go/infra/migrations/007_add_stripe_to_users.sql
Normal 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);
|
||||||
@@ -4,15 +4,28 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
|
|||||||
DROP TABLE IF EXISTS address_checkpoints CASCADE;
|
DROP TABLE IF EXISTS address_checkpoints CASCADE;
|
||||||
DROP TABLE IF EXISTS user_notification_configs CASCADE;
|
DROP TABLE IF EXISTS user_notification_configs CASCADE;
|
||||||
DROP TABLE IF EXISTS addresses CASCADE;
|
DROP TABLE IF EXISTS addresses CASCADE;
|
||||||
|
DROP TABLE IF EXISTS users CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE addresses (
|
CREATE TABLE addresses (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support)
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
|
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
|
||||||
label VARCHAR(255), -- Optional human-readable label
|
label VARCHAR(255), -- Optional human-readable label
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
-- Unique users can track the same address independently
|
|
||||||
UNIQUE(user_id, address)
|
UNIQUE(user_id, address)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -51,7 +64,7 @@ CREATE TABLE address_checkpoints (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE user_notification_configs (
|
CREATE TABLE user_notification_configs (
|
||||||
user_id VARCHAR(128) PRIMARY KEY,
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
||||||
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
||||||
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
||||||
@@ -63,6 +76,8 @@ 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_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);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -51,7 +56,12 @@ func Load() (*Config, error) {
|
|||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||||
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 {
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ package domain
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
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 {
|
type Address struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||||
|
|||||||
199
backend-go/internal/handlers/stripe.go
Normal file
199
backend-go/internal/handlers/stripe.go
Normal 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 + "/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),
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -26,62 +27,114 @@ type errorResponse struct {
|
|||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||||
}
|
}
|
||||||
|
|
||||||
func Authenticate(next http.Handler) http.Handler {
|
// Authenticate verifies the Firebase ID token and auto-provisions a local user
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// record. The local user UUID (not the Firebase UID) is placed into context so
|
||||||
authHeader := r.Header.Get("Authorization")
|
// all downstream handlers use it as the canonical user identifier.
|
||||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||||
log.Println("No Authorization header or invalid format")
|
return func(next http.Handler) http.Handler {
|
||||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
Error: "UNAUTHORIZED",
|
authHeader := r.Header.Get("Authorization")
|
||||||
Message: "No authentication token provided",
|
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
})
|
log.Println("No Authorization header or invalid format")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
if token == "" {
|
|
||||||
log.Println("Empty token")
|
|
||||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
|
||||||
Error: "UNAUTHORIZED",
|
|
||||||
Message: "Invalid token format",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Verifying Firebase token...")
|
|
||||||
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Token verification failed: %v", err)
|
|
||||||
|
|
||||||
errMsg := err.Error()
|
|
||||||
if strings.Contains(errMsg, "expired") {
|
|
||||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
Error: "TOKEN_EXPIRED",
|
Error: "UNAUTHORIZED",
|
||||||
Message: "Authentication token has expired",
|
Message: "No authentication token provided",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
Error: "UNAUTHORIZED",
|
if token == "" {
|
||||||
Message: "Failed to verify authentication token",
|
log.Println("Empty token")
|
||||||
})
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
return
|
Error: "UNAUTHORIZED",
|
||||||
}
|
Message: "Invalid token format",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userID := decoded.UID
|
log.Println("Verifying Firebase token...")
|
||||||
email, _ := decoded.Claims["email"].(string)
|
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Token verification failed: %v", err)
|
||||||
|
|
||||||
log.Printf("Token verified! User ID: %s, Email: %s", userID, email)
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "expired") {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "TOKEN_EXPIRED",
|
||||||
|
Message: "Authentication token has expired",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
ctx = context.WithValue(ctx, UserEmailKey, email)
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "Failed to verify authentication token",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
firebaseUID := decoded.UID
|
||||||
})
|
email, _ := decoded.Claims["email"].(string)
|
||||||
|
|
||||||
|
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "Failed to initialize user account",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
|
||||||
|
ctx = context.WithValue(ctx, UserEmailKey, email)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
|||||||
89
backend-go/internal/models/user.go
Normal file
89
backend-go/internal/models/user.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserModel struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
||||||
|
return &UserModel{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userColumns = `id, firebase_uid, email, display_name,
|
||||||
|
stripe_customer_id, stripe_subscription_id, subscription_status,
|
||||||
|
subscription_created_at, created_at, updated_at`
|
||||||
|
|
||||||
|
func scanUser(row pgx.Row) (*domain.User, error) {
|
||||||
|
var u domain.User
|
||||||
|
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 //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
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/signup" element={<Signup />} />
|
<Route path="/signup" element={<Signup />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/subscribe" element={<Onboarding />} />
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -30,7 +30,7 @@ export default function App() {
|
|||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
<Route path="/alertevents" element={<AlertHistory />} />
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/subscribe" element={<Onboarding />} />
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
27
frontend/src/api/stripe.jsx
Normal file
27
frontend/src/api/stripe.jsx
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.input__label {
|
.input__label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.4rem;
|
margin: 0.8rem 0rem 0.4rem 0rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,4 +18,4 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
.login-heading {
|
.login-heading {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
top: -20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand {
|
.login-brand {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Onboarding Wizard
|
* Subscribe / Onboarding Wizard
|
||||||
*
|
*
|
||||||
* 5-step guided flow: Create Account -> 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";
|
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,6 +15,7 @@ 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";
|
||||||
@@ -28,6 +31,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 +75,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(2);
|
||||||
|
} else if (payment === "cancelled") {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
setStep(1);
|
||||||
|
setError("Payment was cancelled. Please try again.");
|
||||||
|
}
|
||||||
|
}, [currentUser, searchParams, setSearchParams]);
|
||||||
|
|
||||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
// ── Step handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleStep1() {
|
async function handleStep1() {
|
||||||
@@ -87,27 +105,30 @@ export default function Onboarding() {
|
|||||||
setError("Password must be at least 6 characters");
|
setError("Password must be at least 6 characters");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!currentUser) {
|
try {
|
||||||
try {
|
setLoading(true);
|
||||||
setLoading(true);
|
if (!currentUser) {
|
||||||
await signup(data.email, data.password);
|
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);
|
|
||||||
}
|
}
|
||||||
|
const status = await getSubscriptionStatus();
|
||||||
|
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
|
||||||
|
setStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { url } = await createCheckoutSession();
|
||||||
|
window.location.href = url;
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
setStep(2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep2() {
|
async function handleStep2() {
|
||||||
@@ -544,7 +565,7 @@ export default function Onboarding() {
|
|||||||
if (step === 5) return null;
|
if (step === 5) return null;
|
||||||
|
|
||||||
const canSkip = step === 3 || step === 4;
|
const canSkip = step === 3 || step === 4;
|
||||||
const canBack = step > 1;
|
const canBack = step > 2;
|
||||||
|
|
||||||
async function handleNext() {
|
async function handleNext() {
|
||||||
setSkipWarning("");
|
setSkipWarning("");
|
||||||
@@ -566,6 +587,12 @@ export default function Onboarding() {
|
|||||||
setStep((s) => s - 1);
|
setStep((s) => s - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextLabel = step === 1
|
||||||
|
? "Create Account & Subscribe"
|
||||||
|
: step === 4
|
||||||
|
? "Finish"
|
||||||
|
: "Next →";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="onboarding__footer">
|
<div className="onboarding__footer">
|
||||||
<div>
|
<div>
|
||||||
@@ -595,7 +622,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>
|
||||||
@@ -605,11 +632,11 @@ export default function Onboarding() {
|
|||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const stepContent = {
|
const stepContent = {
|
||||||
1: <Step1 />,
|
1: Step1(),
|
||||||
2: <Step2 />,
|
2: Step2(),
|
||||||
3: <Step3 />,
|
3: Step3(),
|
||||||
4: <Step4 />,
|
4: Step4(),
|
||||||
5: <Step5 />,
|
5: Step5(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -617,7 +644,7 @@ export default function Onboarding() {
|
|||||||
<div className="onboarding__container">
|
<div className="onboarding__container">
|
||||||
<h1 className="onboarding__title">Koin Ping</h1>
|
<h1 className="onboarding__title">Koin Ping</h1>
|
||||||
|
|
||||||
<ProgressBar />
|
{ProgressBar()}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert alert--error">{error}</div>
|
<div className="alert alert--error">{error}</div>
|
||||||
@@ -629,7 +656,7 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
<div className="onboarding__card">
|
<div className="onboarding__card">
|
||||||
{stepContent[step]}
|
{stepContent[step]}
|
||||||
<Footer />
|
{Footer()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function Signup() {
|
export default function Signup() {
|
||||||
return <Navigate to="/onboarding" replace />;
|
return <Navigate to="/subscribe" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user