Stripe checkout: products created, signup flow with plan selection, API endpoints

This commit is contained in:
2026-04-13 20:29:08 +02:00
parent ce79bdb43a
commit da059c081e
4 changed files with 235 additions and 60 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ module.exports = {
script: '/var/www/autojobs/backend/start_server.py',
interpreter: 'python3',
env: {
DB_PATH: '/var/www/autojobs/autojobs.db'
DB_PATH: '/var/www/autojobs/autojobs.db',
STRIPE_SECRET_KEY: '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'
},
autorestart: true,
watch: false,
+106 -1
View File
@@ -1,8 +1,10 @@
"""
AutoJobs API — FastAPI Backend
Multi-user job search with per-user API keys + subscription plans
import os
import stripe
"""
from fastapi import FastAPI, HTTPException, Depends, APIRouter
from fastapi import FastAPI, HTTPException, Depends, APIRouter, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
@@ -927,3 +929,106 @@ def admin_reset_usage(user_id: str = None):
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
# --- Stripe Price IDs ---
STRIPE_PRICE_IDS = {
"free": None,
"starter": "price_1TLp9OEqqBlW1z4N8zXPHPjM",
"pro": "price_1TLp9PEqqBlW1z4NmP5sVrA1",
"ultra": "price_1TLp9QEqqBlW1z4NpjjV13Kb",
"unlimited": "price_1TLp9REqqBlW1z4NR2c92fmM",
"agency_starter": "price_1TLp9SEqqBlW1z4Ny9MabLz3",
"agency_growth": "price_1TLp9TEqqBlW1z4N2t2noudV",
"agency_scale": "price_1TLp9UEqqBlW1z4NgWPSg7nr",
"agency_pro": "price_1TLp9VEqqBlW1z4NgtQWlUnv",
"agency_enterprise": None,
}
# --- Stripe Checkout Endpoint ---
@app.post("/autojobs/api/create-checkout")
async def create_checkout(request: Request):
try:
body = await request.json()
plan_id = body.get("plan_id")
user_id = body.get("user_id")
user_type = body.get("user_type", "private")
if not plan_id or not user_id:
return JSONResponse({"error": "Missing plan_id or user_id"}, status_code=400)
price_id = STRIPE_PRICE_IDS.get(plan_id)
if price_id is None and plan_id != "agency_enterprise":
return JSONResponse({"error": "Invalid plan"}, status_code=400)
user = db.get("SELECT * FROM users WHERE id = ?", (user_id,))
if not user:
return JSONResponse({"error": "User not found"}, status_code=404)
if plan_id == "free":
# Free plan - just update and return
db.run("UPDATE users SET plan = ?, user_type = ? WHERE id = ?", (plan_id, user_type, user_id))
return JSONResponse({"success": True, "plan": plan_id})
if plan_id == "agency_enterprise":
return JSONResponse({"error": "Contact us for enterprise pricing"}, status_code=400)
# Get or create Stripe customer
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY")
customer = stripe.Customer.create(
email=user["email"],
name=user["name"],
metadata={"autojobs_user_id": str(user_id)}
)
checkout_session = stripe.checkout.Session.create(
customer=customer.id,
payment_method_types=["card"],
line_items=[{"price": price_id, "quantity": 1}],
mode="subscription",
success_url="https://hostpioneers.com/autojobs/dashboard?session_id={CHECKOUT_SESSION_ID}",
cancel_url="https://hostpioneers.com/autojobs/pricing?canceled=true",
metadata={"user_id": str(user_id), "plan_id": plan_id, "user_type": user_type},
subscription_data={"metadata": {"user_id": str(user_id), "plan_id": plan_id}}
)
return JSONResponse({"checkout_url": checkout_session.url})
except Exception as e:
import traceback
return JSONResponse({"error": str(e), "trace": traceback.format_exc()}, status_code=500)
# --- Stripe Webhook ---
@app.post("/autojobs/api/stripe-webhook")
async def stripe_webhook(request: Request):
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY")
payload = await request.body()
sig = request.headers.get("stripe-signature", "")
webhook_secret = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
try:
if webhook_secret:
event = stripe.Webhook.construct_event(payload, sig, webhook_secret)
else:
event = json.loads(payload)
event_type = event.get("type", "")
if event_type == "checkout.session.completed":
session = event["data"]["object"]
user_id = session["metadata"]["user_id"]
plan_id = session["metadata"]["plan_id"]
stripe_sub_id = session.get("subscription")
db.run("UPDATE users SET plan = ?, stripe_subscription_id = ?, stripe_customer_id = ? WHERE id = ?",
(plan_id, stripe_sub_id, session.get("customer"), user_id))
elif event_type == "customer.subscription.deleted":
sub = event["data"]["object"]
customer_id = sub.get("customer")
db.run("UPDATE users SET plan = 'free' WHERE stripe_customer_id = ?", (customer_id,))
return JSONResponse({"received": True})
except Exception as e:
import traceback
return JSONResponse({"error": str(e), "trace": traceback.format_exc()}, status_code=400)
+25 -8
View File
@@ -2,34 +2,51 @@ import { NextRequest, NextResponse } from "next/server"
import { cookies } from "next/headers"
export async function POST(req: NextRequest) {
const { email, password, name } = await req.json()
const { email, password, name, user_type, plan } = await req.json()
if (!email || !password) {
return NextResponse.json({ error: "Email and password required" }, { status: 400 })
}
// In production: hash password, store in DB
// For MVP: create user in backend DB, set cookie
try {
const res = await fetch(`${process.env.API_URL || "http://localhost:8000"}/users`, {
const apiUrl = process.env.API_URL || "http://localhost:8000"
const res = await fetch(`${apiUrl}/autojobs/api/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: email.split("@")[0], email, name })
body: JSON.stringify({
email,
name,
user_type: user_type || "private",
plan: plan || "free"
})
})
if (!res.ok) {
return NextResponse.json({ error: "Failed to create user" }, { status: 500 })
}
const data = await res.json()
const cookieStore = await cookies()
cookieStore.set("autojobs_user", email.split("@")[0], {
cookieStore.set("autojobs_user", email, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30 // 30 days
maxAge: 60 * 60 * 24 * 30
})
return NextResponse.json({ status: "ok" })
// Also store user_id for checkout
if (data.id) {
cookieStore.set("autojobs_user_id", String(data.id), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30
})
}
return NextResponse.json({ status: "ok", user_id: data.id })
} catch {
return NextResponse.json({ error: "Server error" }, { status: 500 })
}
+100 -48
View File
@@ -1,10 +1,28 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useState, Suspense } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
export default function SignupPage() {
const PLAN_INFO: Record<string, { name: string; price: string; apps: string }> = {
free: { name: "Free", price: "$0/mo", apps: "5 apps" },
starter: { name: "Starter", price: "$29/mo", apps: "20 apps" },
pro: { name: "Pro", price: "$69/mo", apps: "100 apps" },
ultra: { name: "Ultra", price: "$149/mo", apps: "200 apps" },
unlimited: { name: "Unlimited", price: "$199/mo", apps: "Unlimited" },
agency_starter: { name: "Agency Starter", price: "$555/mo", apps: "1,000 submissions" },
agency_growth: { name: "Agency Growth", price: "$999/mo", apps: "3,000 submissions" },
agency_scale: { name: "Agency Scale", price: "$1,499/mo", apps: "5,000 submissions" },
agency_pro: { name: "Agency Pro", price: "$3,699/mo", apps: "10,000 submissions" },
agency_enterprise: { name: "Enterprise", price: "Contact Us", apps: "Unlimited" },
}
function SignupForm() {
const router = useRouter()
const searchParams = useSearchParams()
const planId = searchParams.get("plan") || "free"
const userType = searchParams.get("type") || "private"
const plan = PLAN_INFO[planId] || PLAN_INFO.free
const [form, setForm] = useState({
name: "", email: "", password: "", confirmPassword: ""
})
@@ -24,10 +42,47 @@ export default function SignupPage() {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: form.name, email: form.email, password: form.password })
body: JSON.stringify({
name: form.name,
email: form.email,
password: form.password,
user_type: userType,
plan: planId
})
})
if (res.ok) {
router.push("/autojobs/profile-setup")
const data = await res.json()
if (planId === "free") {
router.push("/autojobs/dashboard")
return
}
if (planId === "agency_enterprise") {
router.push("/autojobs/dashboard")
return
}
const checkoutRes = await fetch("https://hostpioneers.com/autojobs/api/create-checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: data.user_id,
plan_id: planId,
user_type: userType
})
})
if (checkoutRes.ok) {
const checkoutData = await checkoutRes.json()
if (checkoutData.checkout_url) {
window.location.href = checkoutData.checkout_url
return
}
}
router.push("/autojobs/dashboard")
} else {
const data = await res.json()
setError(data.error || "Signup failed")
@@ -39,15 +94,31 @@ export default function SignupPage() {
}
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="text-center mb-6">
<Link href="/autojobs" className="text-2xl font-bold text-white">
Auto<span className="text-blue-400">Jobs</span>
</Link>
<p className="text-slate-400 mt-2">Create your account</p>
</div>
<div className="bg-slate-800 rounded-2xl p-5 border border-blue-500/30 mb-6">
<div className="flex justify-between items-center">
<div>
<div className="text-sm text-slate-400">Selected Plan</div>
<div className="text-xl font-bold text-white">{plan.name}</div>
<div className="text-xs text-slate-400">{plan.apps}/month</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-blue-400">{plan.price}</div>
{planId !== "free" && planId !== "agency_enterprise" && (
<div className="text-xs text-slate-400">billed monthly</div>
)}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="bg-slate-800 rounded-2xl p-8 border border-slate-700">
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
@@ -58,66 +129,47 @@ export default function SignupPage() {
<div className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">Full Name</label>
<input
required
type="text"
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="John Smith"
/>
<input required type="text" value={form.name} onChange={e => setForm({...form, name: e.target.value})}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500" placeholder="John Smith" />
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Email</label>
<input
required
type="email"
value={form.email}
onChange={e => setForm({...form, email: e.target.value})}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="you@example.com"
/>
<input required type="email" value={form.email} onChange={e => setForm({...form, email: e.target.value})}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500" placeholder="you@example.com" />
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Password</label>
<input
required
type="password"
value={form.password}
onChange={e => setForm({...form, password: e.target.value})}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="••••••••"
/>
<input required type="password" value={form.password} onChange={e => setForm({...form, password: e.target.value})}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500" placeholder="••••••••" />
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Confirm Password</label>
<input
required
type="password"
value={form.confirmPassword}
onChange={e => setForm({...form, confirmPassword: e.target.value})}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
placeholder="••••••••"
/>
<input required type="password" value={form.confirmPassword} onChange={e => setForm({...form, confirmPassword: e.target.value})}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500" placeholder="••••••••" />
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full mt-6 py-3 bg-blue-500 hover:bg-blue-600 disabled:bg-slate-600 text-white rounded-xl font-semibold transition"
>
{loading ? "Creating account..." : "Create Account"}
<button type="submit" disabled={loading}
className="w-full mt-6 py-3 bg-blue-500 hover:bg-blue-600 disabled:bg-slate-600 text-white rounded-xl font-semibold transition">
{loading ? "Creating account..." : planId === "free" ? "Create Free Account" : `Pay ${plan.price} & Subscribe`}
</button>
</form>
<p className="text-center text-slate-400 mt-6 text-sm">
Already have an account? <Link href="/autojobs/login" className="text-blue-400 hover:underline">Sign in</Link>
</p>
<p className="text-center text-slate-500 mt-4 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy.
</p>
</div>
</div>
)
}
export default function SignupPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-slate-900 flex items-center justify-center"><div className="text-white">Loading...</div></div>}>
<SignupForm />
</Suspense>
)
}