diff --git a/backend/Makefile b/backend/Makefile index 3c14c94..34f0f86 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -37,7 +37,7 @@ tidy: # Database setup db-setup: - psql -d koin_ping_dev -f infra/schema.sql + psql -d koin_ping -f infra/schema.sql vet: go vet ./... diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index a403a4b..82f8c4c 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -82,6 +82,9 @@ func main() { // Stripe webhook (public — called by Stripe, not authenticated) mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook) + // Onboarding checkout (public — account doesn't exist yet) + mux.HandleFunc("POST "+b+"/stripe/create-onboarding-checkout", stripeHandler.CreateOnboardingCheckout) + // Stripe routes (auth required, NO subscription required) mux.Handle("POST "+b+"/stripe/create-checkout-session", authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession))) diff --git a/backend/internal/handlers/stripe.go b/backend/internal/handlers/stripe.go index e8c7aa8..5f5e330 100644 --- a/backend/internal/handlers/stripe.go +++ b/backend/internal/handlers/stripe.go @@ -145,7 +145,7 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req return } - if s.ClientReferenceID != userID { + if s.ClientReferenceID != "" && s.ClientReferenceID != userID { writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user") return } @@ -204,6 +204,65 @@ func (h *StripeHandler) ActivateFreeTier(w http.ResponseWriter, r *http.Request) }) } +// CreateOnboardingCheckout creates a Stripe Checkout session for a user who +// has not yet created an account. This is a public endpoint (no auth required). +// The Firebase account is created on the frontend only after payment succeeds. +func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Tier string `json:"tier"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body") + return + } + + if body.Email == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email is required") + return + } + + if body.Tier == "" { + body.Tier = "premium" + } + + tier := domain.SubscriptionTier(body.Tier) + if tier != domain.TierPremium && tier != domain.TierPro { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Tier must be 'premium' or 'pro'") + return + } + + priceID, err := h.priceIDForTier(tier) + if err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return + } + + params := &stripe.CheckoutSessionParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(priceID), + 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"), + CustomerEmail: stripe.String(body.Email), + } + params.AddMetadata("tier", string(tier)) + params.AddMetadata("onboarding", "true") + + s, err := checkoutsession.New(params) + if err != nil { + log.Printf("Failed to create onboarding 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}) +} + // CreatePortalSession creates a Stripe Billing Portal session so the user can // manage their subscription (cancel, update payment method, view invoices). func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) { diff --git a/frontend/src/api/stripe.jsx b/frontend/src/api/stripe.jsx index d4c1503..99e1cbe 100644 --- a/frontend/src/api/stripe.jsx +++ b/frontend/src/api/stripe.jsx @@ -15,6 +15,19 @@ export async function createCheckoutSession(tier = "premium") { return res.json(); } +export async function createOnboardingCheckout(email, tier = "premium") { + const res = await fetch(`${API_BASE}/stripe/create-onboarding-checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, tier }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.message || "Failed to create checkout session"); + } + return res.json(); +} + export async function activateFreeTier() { const headers = await getAuthHeaders(); const res = await fetch(`${API_BASE}/stripe/activate-free`, { diff --git a/frontend/src/components/TierPicker.jsx b/frontend/src/components/TierPicker.jsx index 3e3d556..790b784 100644 --- a/frontend/src/components/TierPicker.jsx +++ b/frontend/src/components/TierPicker.jsx @@ -3,13 +3,13 @@ import "./TierPicker.css"; const TIERS = [ { id: "free", - name: "Free Trial Access", + name: "Trial Monitoring", price: "$0", period: "", features: [ - "Monitor 1 blockchain address", - "Daily email digest alert", - "1 transaction alert type per monitored address", + "Monitor 1 blockchain address 24/7", + "Configure alerts to fire on trigger events", + "1 transaction alert type per trigger (email digest)", ], disabledFeatures: [ "Real-time Discord alerts", @@ -19,11 +19,12 @@ const TIERS = [ }, { id: "premium", - name: "Premium Access", + name: "Premium Monitoring", price: "$1.99", period: "/month", features: [ "Monitor 3 blockchain addresses", + "Configure alerts to fire on trigger events", "Daily email digest alert", "Real-time Discord alerts", "Real-time Telegram alerts", @@ -36,11 +37,12 @@ const TIERS = [ }, { id: "pro", - name: "Pro Access", + name: "Professional Monitoring", price: "$11.99", period: "/month", features: [ "Monitor unlimited blockchain addresses", + "Configure alerts to fire on trigger events", "Daily email digest alert", "Real-time Discord alerts", "Real-time Telegram alerts", diff --git a/frontend/src/pages/subscribe/Subscribe.jsx b/frontend/src/pages/subscribe/Subscribe.jsx index 9d1ce2c..971ad06 100644 --- a/frontend/src/pages/subscribe/Subscribe.jsx +++ b/frontend/src/pages/subscribe/Subscribe.jsx @@ -7,7 +7,7 @@ import { updateNotificationConfig, testNotificationChannels, } from "../../api/notificationConfig"; -import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession, activateFreeTier } from "../../api/stripe"; +import { createOnboardingCheckout, verifyCheckoutSession, activateFreeTier } from "../../api/stripe"; import Input from "../../components/Input"; import Button from "../../components/Button"; import TierPicker from "../../components/TierPicker"; @@ -20,8 +20,8 @@ const TIER_LIMITS = { }; const STEPS = [ - "Choose Plan", "Create Account", + "Choose Plan", "Add Wallet", "Alert Rules", "Notifications", @@ -33,8 +33,9 @@ export default function Subscribe() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const [step, setStep] = useState(1); - const [loading, setLoading] = useState(false); + const hasPaymentReturn = searchParams.get("payment") === "success"; + const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1); + const [loading, setLoading] = useState(hasPaymentReturn); const [error, setError] = useState(""); const [skipWarning, setSkipWarning] = useState(""); const [testResults, setTestResults] = useState(null); @@ -79,40 +80,61 @@ export default function Subscribe() { }, [currentUser, navigate]); useEffect(() => { - if (!currentUser) return; const payment = searchParams.get("payment"); const sessionId = searchParams.get("session_id"); - if (payment === "success" && sessionId) { - setSearchParams({}, { replace: true }); - setLoading(true); - verifyCheckoutSession(sessionId) - .then(() => { - setStep(3); - }) - .catch((err) => { - setError("Payment verification failed: " + err.message); - setStep(2); - }) - .finally(() => setLoading(false)); - } else if (payment === "cancelled") { + + if (payment === "cancelled") { setSearchParams({}, { replace: true }); setStep(2); setError("Payment was cancelled. Please try again."); + return; } - }, [currentUser, searchParams, setSearchParams]); + + if (payment !== "success" || !sessionId) return; + + // Phase 1: no account yet — create it. This triggers an auth state change + // which remounts the component (App.jsx swaps route trees). The URL params + // are preserved so Phase 2 runs on the next mount. + if (!currentUser) { + const savedEmail = sessionStorage.getItem("kp_onboard_email"); + const savedPw = sessionStorage.getItem("kp_onboard_pw"); + if (!savedEmail || !savedPw) { + setSearchParams({}, { replace: true }); + setError("Session expired. Please start the signup process again."); + setStep(1); + setLoading(false); + return; + } + setLoading(true); + signup(savedEmail, savedPw).catch((err) => { + setSearchParams({}, { replace: true }); + setError("Account creation failed: " + err.message); + setStep(1); + setLoading(false); + }); + return; + } + + // Phase 2: authenticated — verify the checkout and activate subscription. + setSearchParams({}, { replace: true }); + setLoading(true); + sessionStorage.removeItem("kp_onboard_email"); + sessionStorage.removeItem("kp_onboard_pw"); + + verifyCheckoutSession(sessionId) + .then(() => { + setStep(3); + }) + .catch((err) => { + setError("Payment verification failed: " + err.message); + setStep(2); + }) + .finally(() => setLoading(false)); + }, [currentUser, searchParams, setSearchParams, signup]); // ── Step handlers ───────────────────────────────────────────────────────── function handleStep1() { - setError(""); - if (!data.selectedTier) { - setError("Please select a plan to continue"); - return; - } - setStep(2); - } - - async function handleStep2() { setError(""); if (!data.email || !data.password || !data.confirmPassword) { setError("Please fill in all fields"); @@ -126,24 +148,30 @@ export default function Subscribe() { setError("Password must be at least 6 characters"); return; } + setStep(2); + } + + async function handleStep2() { + setError(""); + if (!data.selectedTier) { + setError("Please select a plan to continue"); + return; + } try { setLoading(true); - if (!currentUser) { - await signup(data.email, data.password); - } if (data.selectedTier === "free") { + if (!currentUser) { + await signup(data.email, data.password); + } await activateFreeTier(); setStep(3); return; } - const status = await getSubscriptionStatus(); - if (status.subscription_status === "active" || status.subscription_status === "trialing") { - setStep(3); - return; - } - const { url } = await createCheckoutSession(data.selectedTier); + sessionStorage.setItem("kp_onboard_email", data.email); + sessionStorage.setItem("kp_onboard_pw", data.password); + const { url } = await createOnboardingCheckout(data.email, data.selectedTier); window.location.href = url; } catch (err) { if (err.code === "auth/email-already-in-use") { @@ -153,8 +181,9 @@ export default function Subscribe() { } else if (err.code === "auth/weak-password") { setError("Password is too weak"); } else { - setError("Failed to create account: " + err.message); + setError("Failed to process plan selection: " + err.message); } + } finally { setLoading(false); } } @@ -325,21 +354,6 @@ export default function Subscribe() { // ── Step content ────────────────────────────────────────────────────────── - function StepChoosePlan() { - return ( - <> -

Choose your plan

-

- Select the plan that works best for you. You can upgrade anytime. -

- set("selectedTier", tier)} - selectedTier={data.selectedTier} - /> - - ); - } - function StepCreateAccount() { return ( <> @@ -373,6 +387,21 @@ export default function Subscribe() { ); } + function StepChoosePlan() { + return ( + <> +

Choose your monitoring plan

+

+ Select the plan that works best for you. You can upgrade anytime. +

+ set("selectedTier", tier)} + selectedTier={data.selectedTier} + /> + + ); + } + function StepAddWallet() { return ( <> @@ -676,11 +705,13 @@ export default function Subscribe() { } const nextLabel = step === 1 - ? "Continue" + ? "Create Account" : step === 2 - ? data.selectedTier === "free" - ? "Create Account" - : "Create Account & Subscribe" + ? !data.selectedTier + ? "Continue" + : data.selectedTier === "free" + ? "Start Free Trial" + : "Subscribe & Continue" : step === 5 ? "Finish" : "Next →"; @@ -711,7 +742,7 @@ export default function Subscribe() { )}