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.

That’s the complete, paste-ready package. Want me to add an “Offer Pause” option next (1-click pause_collection for a billing cycle) to save on churn?

You said:
Yes please
ChatGPT said:
Thought for 6s

Absolutely. Let’s add a respectful “Offer Pause” flow that lets a user pause billing for 1–2 months instead of cancelling, then auto-resume. This uses Stripe’s pause_collection on the subscription and mirrors state in Firestore, plus a tiny UI tweak to your cancel modal.

1) Backend — pause route (secure)

File: server/routes/billing.ts (add next to /billing/cancel)

// POST /billing/pause
// body: { months: 1 | 2, reason?: string, note?: string }
router.post("/billing/pause", requireAuth, async (req: any, res) => {
  try {
    const uid = req.user.uid;
    const { months = 1, reason, note } = req.body as { months?: number; reason?: string; note?: string };

    if (![1, 2].includes(months)) return res.status(400).json({ error: "invalid_months" });

    // Load user → get active subscriptionId
    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" });

    // Resume date (UTC) — simple add 30/60 days
    const now = new Date();
    const resumesAt = new Date(now.getTime() + months * 30 * 24 * 60 * 60 * 1000);
    const resumesAtUnix = Math.floor(resumesAt.getTime() / 1000);

    // Stripe: pause subscription collection (no invoices collected while paused)
    await stripe.subscriptions.update(subId, {
      pause_collection: {
        behavior: "keep_as_draft",   // safe behavior for paused invoices
        resumes_at: resumesAtUnix,   // auto-resume date
      },
    });

    // Firestore: mirror pause
    await userRef.set({
      subscriptionStatus: "paused",
      plan: "pro", // still pro, just paused
      pausedAt: admin.firestore.FieldValue.serverTimestamp(),
      resumeAt: resumesAt,
    }, { merge: true });

    // Optional: log pause event
    await db.collection("pauses").add({
      uid, email, months,
      reason: String(reason || "").slice(0, 60),
      note: String(note || "").slice(0, 500),
      createdAt: admin.firestore.FieldValue.serverTimestamp(),
      stripeSubId: subId,
      resumeAt: resumesAt,
      source: "in_app",
    });

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


Owner note: This keeps the user on Pro (no churn), pauses collection, and resumes automatically. You can change behavior to:

"keep_as_draft" (recommended),

"mark_as_uncollectible", or

"void" (least common).

2) Webhook backstop (auto-resume)

In your Stripe webhook, when a subscription resumes (pause removed), mirror that to Firestore so your dashboard shows them as active again:

// inside webhook handler
if (event.type === "customer.subscription.updated") {
  const sub = event.data.object as Stripe.Subscription;
  const uid = (sub.metadata as any)?.uid || "";

  // If pause_collection cleared → resume
  if (!sub.pause_collection && uid) {
    await db.collection("users").doc(uid).set({
      subscriptionStatus: "active",
      resumeAt: admin.firestore.FieldValue.delete(),
      pausedAt: admin.firestore.FieldValue.delete(),
    }, { merge: true });
  }
}

3) Firestore rules (protect “pauses” logs)
// firestore.rules (merge)
match /pauses/{id} {
  allow read: if request.auth != null && (request.auth.token.role == 'owner' || request.auth.token.role == 'management');
  allow write: if false; // server/Admin SDK only
}

4) Frontend — add “Offer Pause” to the Cancel modal

Update file: src/components/billing/CancelSurveyModal.tsx
Add a small “Offer Pause” section (1–2 months) right above the buttons.

// …inside the component, before the footer buttons…
<label className="mt-4 block text-sm font-medium">Prefer to pause instead?</label>
<div className="mt-2 flex items-center gap-2">
  <button
    type="button"
    className="rounded-lg border px-3 py-1.5"
    onClick={async () => {
      try {
        // send reason/note too (optional)
        const r = await fetch("/billing/pause", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          credentials: "include",
          body: JSON.stringify({ months: 1, reason, note }),
        });
        if (!r.ok) throw new Error(await r.text());
        const data = await r.json();
        alert(`Paused for 1 month. Will resume on ${new Date(data.resumeAt).toLocaleDateString()}.`);
        onClose();
      } catch {
        alert("Pause failed. Please try again.");
      }
    }}
  >
    Pause 1 month
  </button>

  <button
    type="button"
    className="rounded-lg border px-3 py-1.5"
    onClick={async () => {
      try {
        const r = await fetch("/billing/pause", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          credentials: "include",
          body: JSON.stringify({ months: 2, reason, note }),
        });
        if (!r.ok) throw new Error(await r.text());
        const data = await r.json();
        alert(`Paused for 2 months. Will resume on ${new Date(data.resumeAt).toLocaleDateString()}.`);
        onClose();
      } catch {
        alert("Pause failed. Please try again.");
      }
    }}
  >
    Pause 2 months
  </button>
</div>


The existing Confirm cancel button remains for those who really want to cancel.

5) Owner Dashboard tiles (no UI change needed)

Active Subs will stay stable (Pro paused users still count as Pro).

Churn won’t rise from pauses (good!).

Optionally add a small “Paused” card later:

pausedCount = users with subscriptionStatus === "paused"

resumeNext30d = count where resumeAt within next 30 days

6) Replit secrets (confirm)

STRIPE_SECRET_KEY (already set)

Firebase Admin vars (already set)