Here’s a clean, Stripe-only setup you can drop into Replit now—monthly + annual, checkout, billing portal, and webhooks that flip is_paid.

0) Install & env
npm i stripe
# (You already have express, cookie-parser, better-sqlite3, etc.)


.env

APP_BASE_URL=https://<your-repl-url>
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PRICE_ID_MONTHLY=price_monthly_xxx      # $19/mo
STRIPE_PRICE_ID_ANNUAL=price_annual_xxx        # $180/yr
STRIPE_WEBHOOK_SECRET=whsec_xxx                 # from Stripe CLI/dashboard

1) Server: Stripe routes (checkout + portal + webhook)

server/pay/stripe.ts

import { Router } from "express";
import Stripe from "stripe";
import bodyParser from "body-parser";
import { AuthedRequest, requireAuth } from "../auth/middleware";
import { Subs, Users, db } from "../db";

const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20" } as any);
const APP_BASE_URL = process.env.APP_BASE_URL || "http://localhost:3000";
const PRICE_MONTHLY = process.env.STRIPE_PRICE_ID_MONTHLY!;
const PRICE_ANNUAL  = process.env.STRIPE_PRICE_ID_ANNUAL!;
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;

/** Create Checkout Session (cadence: 'monthly' | 'annual') */
router.post("/api/pay/checkout", requireAuth, async (req: AuthedRequest, res) => {
  try {
    const cadence = req.body?.cadence === "annual" ? "annual" : "monthly";
    const priceId = cadence === "annual" ? PRICE_ANNUAL : PRICE_MONTHLY;

    const session = await stripe.checkout.sessions.create({
      mode: "subscription",
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${APP_BASE_URL}/profile?upgraded=1`,
      cancel_url: `${APP_BASE_URL}/pricing?canceled=1`,
      customer_email: req.user!.email,
      metadata: { userId: String(req.user!.id), cadence },
      automatic_tax: { enabled: true },
      allow_promotion_codes: true,
    });

    res.json({ url: session.url });
  } catch (e: any) {
    console.error("checkout error", e?.message);
    res.status(500).json({ error: "Checkout failed" });
  }
});

/** Billing portal */
router.post("/api/pay/portal", requireAuth, async (req: AuthedRequest, res) => {
  try {
    const sub = Subs.findByUser(req.user!.id);
    if (!sub?.stripe_customer_id) return res.status(400).json({ error: "No Stripe customer" });
    const sess = await stripe.billingPortal.sessions.create({
      customer: sub.stripe_customer_id,
      return_url: `${APP_BASE_URL}/profile`,
    });
    res.json({ url: sess.url });
  } catch (e: any) {
    res.status(500).json({ error: "Could not open billing portal" });
  }
});

/** Webhook (raw body) */
router.post("/api/pay/webhook", bodyParser.raw({ type: "application/json" }), async (req, res) => {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"] as string, WEBHOOK_SECRET);
  } catch (err: any) {
    console.error("Webhook signature verify failed", err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  try {
    switch (event.type) {
      case "checkout.session.completed": {
        const s = event.data.object as Stripe.Checkout.Session;
        const userId = Number(s.metadata?.userId || 0);
        const customerId = String(s.customer || "");
        const subId = String(s.subscription || "");
        if (userId) {
          Users.setPaid(userId, true);
          Subs.upsert(userId, customerId, subId, true);
          // (optional) seed notifications if you added that:
          // createNotification(userId, "Your Pro plan is active! 🎉", "billing");
        }
        break;
      }
      case "customer.subscription.deleted": {
        const sub = event.data.object as Stripe.Subscription;
        // Look up by customer or subId if you persist mapping:
        const row = db.prepare("SELECT user_id FROM subscriptions WHERE stripe_subscription_id = ?").get(sub.id) as any;
        if (row?.user_id) {
          Users.setPaid(row.user_id, false);
          Subs.upsert(row.user_id, String(sub.customer), sub.id, false);
        }
        break;
      }
      case "customer.subscription.updated": {
        // Optional: handle status changes (e.g., past_due, paused)
        break;
      }
      default:
        break;
    }
  } catch (err) {
    console.error("Webhook handler error", err);
    return res.status(500).send("Webhook handler error");
  }

  res.json({ received: true });
});

export default router;


Wire routes in server/index.ts

import stripeRoutes from "./pay/stripe";

// mount webhook BEFORE express.json()
app.use("/api/pay/webhook", stripeRoutes);

// then regular middleware
app.use(cors({ origin: true, credentials: true }));
app.use(cookieParser());
app.use(express.json());

// mount other routes…
app.use(stripeRoutes);

2) Frontend: Pricing → Checkout (monthly/annual)

src/pages/Pricing/PricingPage.tsx (call with cadence)

const onUpgrade = async (annual: boolean) => {
  const res = await fetch("/api/pay/checkout", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ cadence: annual ? "annual" : "monthly" })
  });
  const data = await res.json();
  if (data.url) window.location.href = data.url;
};


Call onUpgrade(annual) from your Upgrade button.

Profile → Manage billing

const openPortal = async () => {
  const r = await fetch("/api/pay/portal", { method:"POST", credentials:"include" });
  const d = await r.json();
  if (d.url) window.location.href = d.url;
};

3) Webhook testing (Stripe CLI)

From your local dev (or Replit shell), forward events and set the secret:

stripe listen --forward-to <your-public-url>/api/pay/webhook
# copy the Signing secret -> put in STRIPE_WEBHOOK_SECRET


Trigger a test session in your app (checkout) and verify:

After success → users.is_paid = 1 and subscriptions row populated.

Cancel a sub in Stripe → webhook sets is_paid = 0.

Notes / gotchas

Apple Pay / Google Pay: enabled automatically on Stripe Checkout if your account supports them.

Taxes: automatic_tax.enabled = true (you can remove if not needed).

Annual vs Monthly: you must create two Prices in Stripe and put both IDs in .env.

Billing portal lets users switch cadence later if you enable both prices as Price options in Products.