Here’s the drop-in package (file paths, code, and prompts) matching your stack: React + Tailwind (Vite) and Express + Firebase Admin + Stripe. It’s ready for her to paste in.

1) Frontend — Cancel Survey (drop-in)

File: src/components/billing/CancelSurveyModal.tsx

import { useState } from "react";

const REASONS = [
  "Too expensive",
  "Didn't get value",
  "Missing feature",
  "Technical issues",
  "Temporary",
  "Other",
];

type Props = { open: boolean; onClose: () => void };

export default function CancelSurveyModal({ open, onClose }: Props) {
  const [reason, setReason] = useState(REASONS[0]);
  const [note, setNote] = useState("");
  const [submitting, setSubmitting] = useState(false);
  const [done, setDone] = useState(false);

  async function handleConfirm() {
    try {
      setSubmitting(true);
      const r = await fetch("/billing/cancel", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ reason, note }),
      });
      if (!r.ok) throw new Error(await r.text());
      setDone(true);
    } catch {
      alert("Cancel failed. Please try again.");
    } finally {
      setSubmitting(false);
    }
  }

  if (!open) return null;

  return (
    <div className="fixed inset-0 z-50 grid place-items-center bg-black/40">
      <div className="w-full max-w-md rounded-2xl bg-white p-5 shadow-xl">
        {!done ? (
          <>
            <h3 className="text-lg font-semibold">Before you go</h3>
            <p className="mt-1 text-sm text-slate-600">
              What’s the main reason you’re cancelling? Your feedback helps us improve.
            </p>

            <label className="mt-4 block text-sm font-medium">Reason</label>
            <select
              className="mt-1 w-full rounded-lg border p-2"
              value={reason}
              onChange={(e) => setReason(e.target.value)}
            >
              {REASONS.map((r) => (
                <option key={r}>{r}</option>
              ))}
            </select>

            <label className="mt-4 block text-sm font-medium">
              Additional comments (optional)
            </label>
            <textarea
              rows={3}
              className="mt-1 w-full rounded-lg border p-2"
              value={note}
              onChange={(e) => setNote(e.target.value)}
            />

            <div className="mt-5 flex justify-end gap-2">
              <button onClick={onClose} className="rounded-lg border px-3 py-2">
                Keep plan
              </button>
              <button
                onClick={handleConfirm}
                disabled={submitting}
                className="rounded-lg bg-slate-900 px-3 py-2 text-white disabled:opacity-60"
              >
                {submitting ? "Cancelling…" : "Confirm cancel"}
              </button>
            </div>
          </>
        ) : (
          <>
            <h3 className="text-lg font-semibold">Cancellation scheduled</h3>
            <p className="mt-1 text-sm text-slate-600">
              Your subscription will end at the current period’s end. Thank you for the feedback.
            </p>
            <div className="mt-5 flex justify-end">
              <button onClick={onClose} className="rounded-lg bg-slate-900 px-3 py-2 text-white">
                Done
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}


Usage (add button + modal): src/pages/AccountBilling.tsx

import { useState } from "react";
import CancelSurveyModal from "@/components/billing/CancelSurveyModal";

export default function AccountBilling() {
  const [open, setOpen] = useState(false);

  return (
    <>
      {/* …your current plan UI… */}
      <button className="mt-3 rounded-lg border px-3 py-2" onClick={() => setOpen(true)}>
        Cancel subscription
      </button>
      <CancelSurveyModal open={open} onClose={() => setOpen(false)} />
    </>
  );
}

2) Backend — Express route (save + cancel at period end)

File: server/routes/billing.ts

import { Router } from "express";
import Stripe from "stripe";
import admin from "firebase-admin";
import { db } from "../services/firestore";
import { requireAuth } from "../middleware/requireAuth"; // your JWT decode -> req.user

const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2023-10-16" });

router.post("/billing/cancel", requireAuth, async (req: any, res) => {
  try {
    const uid = req.user.uid;
    const { reason, note } = req.body as { reason: string; note?: string };

    const userRef = db.collection("users").doc(uid);
    const snap = await userRef.get();
    if (!snap.exists) return res.status(404).json({ error: "user_not_found" });
    const user = snap.data()!;
    const subId = user.subscriptionId as string | undefined;
    const email = user.email || req.user.email;

    if (!subId) return res.status(400).json({ error: "no_active_subscription" });

    await db.collection("cancellations").add({
      uid,
      email,
      reason: String(reason || "").slice(0, 60),
      note: String(note || "").slice(0, 500),
      createdAt: admin.firestore.FieldValue.serverTimestamp(),
      stripeSubId: subId,
      source: "in_app",
    });

    await stripe.subscriptions.update(subId, { cancel_at_period_end: true });

    await userRef.update({
      plan: "free",
      subscriptionStatus: "cancelling",
      cancelledAt: admin.firestore.FieldValue.serverTimestamp(),
    });

    res.json({ ok: true });
  } catch (e: any) {
    console.error("cancel_failed", e);
    res.status(500).json({ error: "cancel_failed", message: e.message });
  }
});

export default router;


Wire it: in server/index.ts

import billingRoutes from "./routes/billing";
app.use(billingRoutes);

3) Stripe webhook — log portal cancels too

In your existing webhook handler (server/routes/stripeWebhook.ts), add:

// inside switch(event.type) or if/else:
if (event.type === "customer.subscription.deleted" || event.type === "customer.subscription.updated") {
  const sub = event.data.object as Stripe.Subscription;
  const status = sub.status;
  const uid = (sub.metadata as any)?.uid || "";
  const email = (sub.metadata as any)?.email || "";

  if (status === "canceled" || sub.cancel_at_period_end) {
    await db.collection("cancellations").add({
      uid,
      email,
      createdAt: admin.firestore.FieldValue.serverTimestamp(),
      stripeSubId: sub.id,
      source: "portal",          // we didn’t show the in-app modal
    }).catch(()=>{});
  }

  if (uid) {
    await db.collection("users").doc(uid).set({
      subscriptionId: sub.id,
      subscriptionStatus: status,
      plan: status === "active" ? "pro" : "free",
    }, { merge: true });
  }
}


Important: When creating subs, include metadata so the webhook can join:

await stripe.subscriptions.create({
  customer,
  items: [{ price: PRICE_ID }],
  metadata: { uid, email },
});

4) Firestore rules (protect cancellations)
// firestore.rules (merge with your existing rules)
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isOwnerOrMgmt() {
      return request.auth != null &&
             (request.auth.token.role == 'owner' || request.auth.token.role == 'management');
    }
    match /cancellations/{id} {
      allow read: if isOwnerOrMgmt(); // only owner/management can view
      allow write: if false;          // only server writes via Admin SDK
    }
  }
}

5) Owner metrics tile (Unsatisfied %) — you already have stub

Ensure your /admin/metrics computes from Firestore:

const cancelSnap = await db.collection("cancellations")
  .where("createdAt", ">=", startOfMonth())
  .get();

const totalCancels = cancelSnap.size;
const unsatisfied = cancelSnap.docs.filter(d => {
  const r = (d.get("reason") || "").toString().toLowerCase();
  return r.includes("didn't get value") || r.includes("unsatisfied");
}).length;

const unsatisfiedPct = totalCancels ? Number(((unsatisfied / totalCancels) * 100).toFixed(1)) : 0;

6) Replit secrets checklist (must be set)

STRIPE_SECRET_KEY

FIREBASE_PROJECT_ID

FIREBASE_CLIENT_EMAIL

FIREBASE_PRIVATE_KEY (with \n escaped; code replaces them)

7) Owner-only note

The /admin/metrics endpoint remains owner-only via requireOwner.

The cancellations collection is readable only by Owner/Management; Staff/Analyst can’t see reasons.