UX logic (exact behavior)

If the user already owns the template → Download starts immediately.

If not owned → we launch Stripe Checkout; after payment, Stripe returns to your page and the download auto-starts.

If the item is free (you won’t have this now, but supported) → immediate download.

What I’m shipping (copy/paste)

I’m giving you tiny, surgical patches so the UI works exactly like that. You already have most of the backend; these add the ownership check + download.

1) Server: ownership + download endpoints
a) “Do I own this?”

server/routes.coverTemplates.ts

// Has this user purchased this template?
router.get("/api/cover-templates/:id/my-status", authenticate, async (req: any, res) => {
  const rows = await db("cover_purchases")
    .where({ user_id: req.user.id, template_id: req.params.id, status: "paid" })
    .orderBy("created_at", "desc")
    .limit(1);

  if (!rows.length) return res.json({ owned: false });

  return res.json({ owned: true, download_url: rows[0].download_url, purchase_id: rows[0].id });
});

b) “Give me the file (only if I own it)”

server/routes.coverTemplates.ts

// Secure download: only for owners
router.get("/api/cover-templates/:id/download", authenticate, async (req: any, res) => {
  const row = await db("cover_purchases")
    .where({ user_id: req.user.id, template_id: req.params.id, status: "paid" })
    .orderBy("created_at", "desc")
    .first();

  if (!row || !row.download_url) return res.status(403).json({ error: "Not purchased" });

  // Option A: redirect to signed URL if your storage provides one
  return res.redirect(row.download_url);

  // Option B: stream the file through your server (if you store locally / need headers)
  // const stream = fs.createReadStream(localPathFrom(row.download_url));
  // res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
  // stream.pipe(res);
});

c) Add purchase lookup (for auto-download after Stripe)

server/routes.coverTemplates.ts

// Lookup a specific purchase (used by success page polling)
router.get("/api/purchases/:id", authenticate, async (req: any, res) => {
  const p = await db("cover_purchases").where({ id: req.params.id, user_id: req.user.id }).first();
  if (!p) return res.status(404).json({ error: "Not found" });
  res.json(p); // includes status, download_url
});

d) Include purchaseId in the Stripe success return

(Adjust your existing checkout route so we know what to poll on return.)

router.post("/api/cover-templates/:id/checkout", authenticate, async (req: any, res) => {
  const tpl = await getCoverTemplate(req.params.id);
  if (!tpl || !tpl.is_active) return res.status(404).json({ error: "Not found" });

  const { customImageUrl } = req.body || {};

  // 1) Create the DB purchase now (pending)
  const rec = await createPurchase({
    user_id: req.user.id,
    template_id: tpl.id,
    custom_image_url: customImageUrl || null,
    amount_cents: parseInt(process.env.COVER_TEMPLATE_PRICE_CENTS || "1499", 10),
    currency: process.env.COVER_TEMPLATE_CURRENCY || "usd",
    stripe_session_id: null,
  });

  // 2) Create Checkout; embed purchaseId so success page can poll
  const successUrl = `${process.env.APP_BASE_URL}/business-assets/templates/cover-dividers?success=true&purchase=${rec.id}`;
  const cancelUrl  = `${process.env.APP_BASE_URL}/business-assets/templates/cover-dividers?cancel=true`;

  const session = await createCheckoutSession({
    successUrl,
    cancelUrl,
    metadata: { templateId: tpl.id, customImageUrl: customImageUrl || "", purchaseId: rec.id }
  });

  // 3) Backfill session id on the purchase
  await db("cover_purchases").where({ id: rec.id }).update({ stripe_session_id: session.id });

  res.json({ checkoutUrl: session.url, purchaseId: rec.id });
});


Your webhook already marks the purchase paid and sets download_url. It now also has purchaseId in session.metadata if you want to cross-check.

2) Client: button logic for “Buy Now → Download”
a) Change the button in the lightbox to “download if owned, else checkout”

In your CoverDividerTemplates.tsx:

async function buyOrDownload(tplId: string) {
  // 1) Check ownership
  const s = await fetch(`/api/cover-templates/${tplId}/my-status`, { credentials: "include" });
  if (s.ok) {
    const j = await s.json();
    if (j.owned && j.download_url) {
      // Already purchased → start download immediately
      window.location.href = `/api/cover-templates/${tplId}/download`;
      return;
    }
  }

  // 2) Not owned → start checkout
  const r = await fetch(`/api/cover-templates/${tplId}/checkout`, {
    method: "POST",
    headers: { "Content-Type":"application/json" },
    credentials: "include",
    body: JSON.stringify({}) // include customImageUrl here if you later add it
  });
  if (!r.ok) { toast.error("Could not start checkout"); return; }
  const { checkoutUrl } = await r.json();
  window.location.href = checkoutUrl;
}


Wire this to the button:

<Button className="w-full" onClick={() => buyOrDownload(sel.id)}>Buy Now</Button>

b) Auto-download on success return

Still in the same page component, add this effect to catch ?success=true&purchase=...:

useEffect(() => {
  const sp = new URLSearchParams(window.location.search);
  if (sp.get("success") === "true" && sp.get("purchase")) {
    const purchaseId = sp.get("purchase")!;
    // Poll a few times in case webhook lands a second later
    let tries = 0;
    const poll = async () => {
      tries++;
      const r = await fetch(`/api/purchases/${purchaseId}`, { credentials: "include" });
      if (r.ok) {
        const j = await r.json();
        if (j.status === "paid" && j.download_url) {
          window.location.href = j.download_url; // or /api/cover-templates/:id/download
          return;
        }
      }
      if (tries < 12) setTimeout(poll, 1500);
      else toast.info("Payment received. Your download will appear in ‘My Purchases’ shortly.");
    };
    poll();
  }
}, []);


This makes the download start automatically after Stripe redirects back, even if the webhook lands a second later.

3) Optional: “Download” label when owned

If you want the button to say Download instead of Buy Now for owners, fetch my-status when the modal opens and set a owned flag to adjust the label:

const [owned, setOwned] = useState(false);
useEffect(()=>{
  if (!sel) return;
  fetch(`/api/cover-templates/${sel.id}/my-status`, { credentials:"include" })
    .then(r=>r.json()).then(j=>setOwned(!!j.owned)).catch(()=>{});
},[sel]);

<Button className="w-full" onClick={() => buyOrDownload(sel!.id)}>
  {owned ? "Download" : "Buy Now"}
</Button>

4) Security notes (download URLs)

If your download_url is to S3/Cloud storage, generate short-lived signed URLs.

If you prefer tighter control, always route downloads through /api/cover-templates/:id/download and stream/redirect from there (we added that).

5) QA (what to test)

Open a template you haven’t bought → click Buy Now → Stripe → pay → redirect → file auto-downloads.

After buying, open the same template → click button → instant download (no Stripe).

If webhook is slightly delayed → the polling shows download within ~15–20s; otherwise a friendly toast.

Unauthorized user → /download returns 403.