Chockfull

This commit is contained in:
KS Jannette
2026-03-28 11:10:25 -04:00
parent 288092e4b4
commit 615dd1dddc
6 changed files with 178 additions and 70 deletions

View File

@@ -37,7 +37,7 @@ tidy:
# Database setup # Database setup
db-setup: db-setup:
psql -d koin_ping_dev -f infra/schema.sql psql -d koin_ping -f infra/schema.sql
vet: vet:
go vet ./... go vet ./...

View File

@@ -82,6 +82,9 @@ func main() {
// Stripe webhook (public — called by Stripe, not authenticated) // Stripe webhook (public — called by Stripe, not authenticated)
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook) 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) // Stripe routes (auth required, NO subscription required)
mux.Handle("POST "+b+"/stripe/create-checkout-session", mux.Handle("POST "+b+"/stripe/create-checkout-session",
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession))) authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))

View File

@@ -145,7 +145,7 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
return return
} }
if s.ClientReferenceID != userID { if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user") writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
return 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 // CreatePortalSession creates a Stripe Billing Portal session so the user can
// manage their subscription (cancel, update payment method, view invoices). // manage their subscription (cancel, update payment method, view invoices).
func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) { func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {

View File

@@ -15,6 +15,19 @@ export async function createCheckoutSession(tier = "premium") {
return res.json(); 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() { export async function activateFreeTier() {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/stripe/activate-free`, { const res = await fetch(`${API_BASE}/stripe/activate-free`, {

View File

@@ -3,13 +3,13 @@ import "./TierPicker.css";
const TIERS = [ const TIERS = [
{ {
id: "free", id: "free",
name: "Free Trial Access", name: "Trial Monitoring",
price: "$0", price: "$0",
period: "", period: "",
features: [ features: [
"Monitor 1 blockchain address", "Monitor 1 blockchain address 24/7",
"Daily email digest alert", "Configure alerts to fire on trigger events",
"1 transaction alert type per monitored address", "1 transaction alert type per trigger (email digest)",
], ],
disabledFeatures: [ disabledFeatures: [
"Real-time Discord alerts", "Real-time Discord alerts",
@@ -19,11 +19,12 @@ const TIERS = [
}, },
{ {
id: "premium", id: "premium",
name: "Premium Access", name: "Premium Monitoring",
price: "$1.99", price: "$1.99",
period: "/month", period: "/month",
features: [ features: [
"Monitor 3 blockchain addresses", "Monitor 3 blockchain addresses",
"Configure alerts to fire on trigger events",
"Daily email digest alert", "Daily email digest alert",
"Real-time Discord alerts", "Real-time Discord alerts",
"Real-time Telegram alerts", "Real-time Telegram alerts",
@@ -36,11 +37,12 @@ const TIERS = [
}, },
{ {
id: "pro", id: "pro",
name: "Pro Access", name: "Professional Monitoring",
price: "$11.99", price: "$11.99",
period: "/month", period: "/month",
features: [ features: [
"Monitor unlimited blockchain addresses", "Monitor unlimited blockchain addresses",
"Configure alerts to fire on trigger events",
"Daily email digest alert", "Daily email digest alert",
"Real-time Discord alerts", "Real-time Discord alerts",
"Real-time Telegram alerts", "Real-time Telegram alerts",

View File

@@ -7,7 +7,7 @@ import {
updateNotificationConfig, updateNotificationConfig,
testNotificationChannels, testNotificationChannels,
} from "../../api/notificationConfig"; } from "../../api/notificationConfig";
import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession, activateFreeTier } from "../../api/stripe"; import { createOnboardingCheckout, verifyCheckoutSession, activateFreeTier } from "../../api/stripe";
import Input from "../../components/Input"; import Input from "../../components/Input";
import Button from "../../components/Button"; import Button from "../../components/Button";
import TierPicker from "../../components/TierPicker"; import TierPicker from "../../components/TierPicker";
@@ -20,8 +20,8 @@ const TIER_LIMITS = {
}; };
const STEPS = [ const STEPS = [
"Choose Plan",
"Create Account", "Create Account",
"Choose Plan",
"Add Wallet", "Add Wallet",
"Alert Rules", "Alert Rules",
"Notifications", "Notifications",
@@ -33,8 +33,9 @@ export default function Subscribe() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [step, setStep] = useState(1); const hasPaymentReturn = searchParams.get("payment") === "success";
const [loading, setLoading] = useState(false); const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
const [loading, setLoading] = useState(hasPaymentReturn);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [skipWarning, setSkipWarning] = useState(""); const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null); const [testResults, setTestResults] = useState(null);
@@ -79,40 +80,61 @@ export default function Subscribe() {
}, [currentUser, navigate]); }, [currentUser, navigate]);
useEffect(() => { useEffect(() => {
if (!currentUser) return;
const payment = searchParams.get("payment"); const payment = searchParams.get("payment");
const sessionId = searchParams.get("session_id"); const sessionId = searchParams.get("session_id");
if (payment === "success" && sessionId) {
setSearchParams({}, { replace: true }); if (payment === "cancelled") {
setLoading(true);
verifyCheckoutSession(sessionId)
.then(() => {
setStep(3);
})
.catch((err) => {
setError("Payment verification failed: " + err.message);
setStep(2);
})
.finally(() => setLoading(false));
} else if (payment === "cancelled") {
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
setStep(2); setStep(2);
setError("Payment was cancelled. Please try again."); 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 ───────────────────────────────────────────────────────── // ── Step handlers ─────────────────────────────────────────────────────────
function handleStep1() { function handleStep1() {
setError("");
if (!data.selectedTier) {
setError("Please select a plan to continue");
return;
}
setStep(2);
}
async function handleStep2() {
setError(""); setError("");
if (!data.email || !data.password || !data.confirmPassword) { if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields"); setError("Please fill in all fields");
@@ -126,24 +148,30 @@ export default function Subscribe() {
setError("Password must be at least 6 characters"); setError("Password must be at least 6 characters");
return; return;
} }
setStep(2);
}
async function handleStep2() {
setError("");
if (!data.selectedTier) {
setError("Please select a plan to continue");
return;
}
try { try {
setLoading(true); setLoading(true);
if (!currentUser) {
await signup(data.email, data.password);
}
if (data.selectedTier === "free") { if (data.selectedTier === "free") {
if (!currentUser) {
await signup(data.email, data.password);
}
await activateFreeTier(); await activateFreeTier();
setStep(3); setStep(3);
return; return;
} }
const status = await getSubscriptionStatus(); sessionStorage.setItem("kp_onboard_email", data.email);
if (status.subscription_status === "active" || status.subscription_status === "trialing") { sessionStorage.setItem("kp_onboard_pw", data.password);
setStep(3); const { url } = await createOnboardingCheckout(data.email, data.selectedTier);
return;
}
const { url } = await createCheckoutSession(data.selectedTier);
window.location.href = url; window.location.href = url;
} catch (err) { } catch (err) {
if (err.code === "auth/email-already-in-use") { if (err.code === "auth/email-already-in-use") {
@@ -153,8 +181,9 @@ export default function Subscribe() {
} else if (err.code === "auth/weak-password") { } else if (err.code === "auth/weak-password") {
setError("Password is too weak"); setError("Password is too weak");
} else { } else {
setError("Failed to create account: " + err.message); setError("Failed to process plan selection: " + err.message);
} }
} finally {
setLoading(false); setLoading(false);
} }
} }
@@ -325,21 +354,6 @@ export default function Subscribe() {
// ── Step content ────────────────────────────────────────────────────────── // ── Step content ──────────────────────────────────────────────────────────
function StepChoosePlan() {
return (
<>
<h2 className="mb-sm">Choose your plan</h2>
<p className="subscribe__subtitle">
Select the plan that works best for you. You can upgrade anytime.
</p>
<TierPicker
onSelect={(tier) => set("selectedTier", tier)}
selectedTier={data.selectedTier}
/>
</>
);
}
function StepCreateAccount() { function StepCreateAccount() {
return ( return (
<> <>
@@ -373,6 +387,21 @@ export default function Subscribe() {
); );
} }
function StepChoosePlan() {
return (
<>
<h2 className="mb-sm">Choose your monitoring plan</h2>
<p className="subscribe__subtitle">
Select the plan that works best for you. You can upgrade anytime.
</p>
<TierPicker
onSelect={(tier) => set("selectedTier", tier)}
selectedTier={data.selectedTier}
/>
</>
);
}
function StepAddWallet() { function StepAddWallet() {
return ( return (
<> <>
@@ -676,11 +705,13 @@ export default function Subscribe() {
} }
const nextLabel = step === 1 const nextLabel = step === 1
? "Continue" ? "Create Account"
: step === 2 : step === 2
? data.selectedTier === "free" ? !data.selectedTier
? "Create Account" ? "Continue"
: "Create Account & Subscribe" : data.selectedTier === "free"
? "Start Free Trial"
: "Subscribe & Continue"
: step === 5 : step === 5
? "Finish" ? "Finish"
: "Next →"; : "Next →";
@@ -711,7 +742,7 @@ export default function Subscribe() {
)} )}
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={loading || (step === 1 && !data.selectedTier)} disabled={loading || (step === 2 && !data.selectedTier)}
className="text-bold" className="text-bold"
> >
{loading ? "Please wait..." : nextLabel} {loading ? "Please wait..." : nextLabel}
@@ -724,8 +755,8 @@ export default function Subscribe() {
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
const stepContent = { const stepContent = {
1: StepChoosePlan(), 1: StepCreateAccount(),
2: StepCreateAccount(), 2: StepChoosePlan(),
3: StepAddWallet(), 3: StepAddWallet(),
4: StepAlertRules(), 4: StepAlertRules(),
5: StepNotifications(), 5: StepNotifications(),
@@ -734,7 +765,7 @@ export default function Subscribe() {
return ( return (
<div className="subscribe"> <div className="subscribe">
<div className={`subscribe__container${step === 1 ? " subscribe__container--wide" : ""}`}> <div className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}>
<h1 className="subscribe__title">Koin Ping</h1> <h1 className="subscribe__title">Koin Ping</h1>
{ProgressBar()} {ProgressBar()}
@@ -752,7 +783,7 @@ export default function Subscribe() {
{Footer()} {Footer()}
</div> </div>
{step === 2 && ( {step === 1 && (
<p className="subscribe__login-link"> <p className="subscribe__login-link">
Already have an account?{" "} Already have an account?{" "}
<a href="/login">Log in here</a> <a href="/login">Log in here</a>