Here’s a tight, drop-in implementation (frontend modal + secure backend + webhook) you can hand to Replit. I’ll mark owner-only bits and exactly where to wire them.

0) Data shape (Firestore)

We’ll log each cancel in a cancellations collection:

{
  "uid": "abc123",
  "email": "user@ex.com",
  "reason": "Too expensive",
  "note": "…optional…",
  "createdAt": <server timestamp>,
  "stripeSubId": "sub_123",
  "source": "in_app" // or "portal" (from webhook)
}

1) Firestore rules (safe)

Block normal users from writing arbitrary cancels; only your backend (Admin SDK) can.

// firestore.rules (add/merge)
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isOwner() {
      return request.auth != null && (request.auth.token.role == 'owner' || request.auth.token.role == 'management');
    }

    match /cancellations/{id} {
      allow read: if isOwner();      // owner/management can read
      allow write: if false;         // only server writes via Admin SDK
    }
  }
}

2) Backend — API to save survey + cancel Stripe

Create a secure route your app calls after the user picks a reason.

Prompt for Replit (server):
Add POST /billing/cancel that (1) validates auth, (2) writes a cancellation doc via Admin SDK, (3) cancels the Stripe subscription (cancel at period end), and (4) returns {ok:true}. Use requireAuth and your Firestore db. Assumes user doc has stripeCustomerId and current subscriptionId (or look it up).

// server/routes/billing.ts
import { Router } from "express";
import Stripe from "stripe";
import { db } from "../services/firestore";
import { requireAuth } from "../middleware/requireAuth"; // your existing auth decode
import admin from "firebase-admin";

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 };

    // Fetch user to find subscription
    const userSnap = await db.collection("users").doc(uid).get();
    if (!userSnap.exists) return res.status(404).json({ error: "user_not_found" });
    const user = userSnap.data()!;
    const subId = user.subscriptionId;         // set by your Stripe webhook on subscribe
    const email = user.email || req.user.email;

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

    // Save cancellation feedback (server-side write)
    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",
    });

    // Cancel at period end (less jarring)
    await stripe.subscriptions.update(subId, { cancel_at_period_end: true });

    // Optionally also mark plan in user doc now:
    await db.collection("users").doc(uid).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 your server index:

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

3) Stripe webhook — backstop + “portal” cancels

If a user cancels via the Stripe Customer Portal (your app won’t see your modal), the webhook will still log a cancellation so your metrics stay accurate.

Prompt for Replit (server):
In webhook handler, on customer.subscription.deleted or when cancel_at_period_end flips, upsert a cancellations doc with source:"portal" if one doesn’t already exist for that sub & time window.

// server/routes/stripeWebhook.ts (inside your existing handler)
import admin from "firebase-admin";
import { db } from "../services/firestore";
// ...
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?.uid || "";        // store uid to metadata when creating sub
  const email = sub.metadata?.email || "";

  // Only log when actually cancelled (or cancellation scheduled)
  if (status === "canceled" || sub.cancel_at_period_end) {
    await db.collection("cancellations").add({
      uid,
      email,
      reason: admin.firestore.FieldValue.delete(), // unknown (portal) unless later enriched
      note: admin.firestore.FieldValue.delete(),
      createdAt: admin.firestore.FieldValue.serverTimestamp(),
      stripeSubId: sub.id,
      source: "portal",
    }).catch(() => {}); // tolerate duplicates
  }

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


(Make sure your subscription creation code sets metadata: { uid, email } so webhook can join data.)

4) Frontend — Cancel modal component

Drop-in modal + integration with your “Cancel” button.

// src/components/billing/CancelSurveyModal.tsx
import { useState } from "react";

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

export default function CancelSurveyModal({ open, onClose }: { open: boolean; onClose: () => void; }) {
  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 (e) {
      alert("Cancel failed. Please try again.");
    } finally {
      setSubmitting(false);
    }
  }

  if (!open) return null;

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

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

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

            <div className="mt-5 flex justify-end gap-2">
              <button onClick={onClose} className="px-3 py-2 rounded-lg border">Keep plan</button>
              <button
                onClick={handleConfirm}
                disabled={submitting}
                className="px-3 py-2 rounded-lg bg-slate-900 text-white disabled:opacity-60"
              >
                {submitting ? "Cancelling…" : "Confirm cancel"}
              </button>
            </div>
          </>
        ) : (
          <>
            <h3 className="text-lg font-semibold">Cancellation scheduled</h3>
            <p className="text-sm text-slate-600 mt-1">
              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="px-3 py-2 rounded-lg bg-slate-900 text-white">Done</button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}


Use it on your billing/settings page:

// src/pages/AccountBilling.tsx (example)
import { useState } from "react";
import CancelSurveyModal from "@/components/billing/CancelSurveyModal";

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

  return (
    <>
      {/* …your plan details… */}
      <button className="mt-3 px-3 py-2 rounded-lg border" onClick={() => setOpen(true)}>
        Cancel subscription
      </button>

      <CancelSurveyModal open={open} onClose={() => setOpen(false)} />
    </>
  );
}

5) Owner dashboard tile (Unsatisfied %)

You already have the placeholder; with data flowing, your existing /admin/metrics can compute:

// in /admin/metrics (already shared earlier)
const cancelSnap = await db.collection("cancellations")
  .where("createdAt", ">=", startOfMonth())
  .get();
const totalCancels = cancelSnap.size;
const unsatisfied = cancelSnap.docs.filter(d =>
  (d.get("reason") || "").toLowerCase().includes("didn") // "Didn't get value"
  || (d.get("reason") || "").toLowerCase().includes("unsatisfied")
).length;
const unsatisfiedPct = totalCancels ? Number(((unsatisfied / totalCancels) * 100).toFixed(1)) : 0;

6) Stripe metadata (important)

When creating subscriptions, store uid and email so the webhook can join:

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

7) Env/Secrets checklist (Replit)

STRIPE_SECRET_KEY

FIREBASE_PROJECT_ID

FIREBASE_CLIENT_EMAIL

FIREBASE_PRIVATE_KEY (escaped newlines \n)