Chockfull
This commit is contained in:
@@ -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 ./...
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,12 +80,47 @@ 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) {
|
|
||||||
|
if (payment === "cancelled") {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
setStep(2);
|
||||||
|
setError("Payment was cancelled. Please try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
setSearchParams({}, { replace: true });
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
sessionStorage.removeItem("kp_onboard_email");
|
||||||
|
sessionStorage.removeItem("kp_onboard_pw");
|
||||||
|
|
||||||
verifyCheckoutSession(sessionId)
|
verifyCheckoutSession(sessionId)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setStep(3);
|
setStep(3);
|
||||||
@@ -94,25 +130,11 @@ export default function Subscribe() {
|
|||||||
setStep(2);
|
setStep(2);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
} else if (payment === "cancelled") {
|
}, [currentUser, searchParams, setSearchParams, signup]);
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
setStep(2);
|
|
||||||
setError("Payment was cancelled. Please try again.");
|
|
||||||
}
|
|
||||||
}, [currentUser, searchParams, setSearchParams]);
|
|
||||||
|
|
||||||
// ── 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 (data.selectedTier === "free") {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
await signup(data.email, data.password);
|
await signup(data.email, data.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.selectedTier === "free") {
|
|
||||||
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"
|
|
||||||
: step === 2
|
|
||||||
? data.selectedTier === "free"
|
|
||||||
? "Create Account"
|
? "Create Account"
|
||||||
: "Create Account & Subscribe"
|
: step === 2
|
||||||
|
? !data.selectedTier
|
||||||
|
? "Continue"
|
||||||
|
: 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user