Here’s the complete, copy-paste bundle to make Buy Now → instant download work perfectly, including:

Ownership check + secure download endpoint

Stripe success auto-download flow

“Owned → Download” button state

My Purchases page (stub)

Optional S3 signed URL helper (swap in if you use S3)

Then you’re clear to jump to Presentation Templates.

0) DB — nothing new

We’ll use your existing cover_templates and cover_purchases tables from earlier.

1) Server: routes (ownership, checkout w/ purchaseId, download, purchase lookup)
1.1 server/routes.coverTemplates.ts (additions/updates)
// server/routes.coverTemplates.ts
import express from "express";
import { db } from "../db"; // your db client
import {
  listCoverTemplates, getCoverTemplate, createCoverTemplate, createPurchase,
} from "../storage/coverTemplates";
import { createCheckoutSession } from "../stripe/coverCheckout";
// If you want S3 signed URLs, import getSignedUrl below (see section 4)
import { getSignedDownloadUrl } from "../storage/signedUrls"; // optional helper

const router = express.Router();

// Replace with your real auth middlewares
const authenticate = (req: any, res: any, next: any) => { if (!req.user) return res.status(401).json({ error: "auth" }); next(); };

// ---- FILTERED LIST & DETAILS (you already have, shown for context)
router.get("/api/cover-templates", async (req, res) => {
  const rows = await listCoverTemplates({
    q: req.query.q as string | undefined,
    category: req.query.category as string | undefined,
    top_tier: req.query.toptier as any,
    subcat: req.query.subcat as string | undefined,
    min: req.query.min ? parseInt(req.query.min as string,10) : undefined,
    max: req.query.max ? parseInt(req.query.max as string,10) : undefined,
  });
  res.json(rows);
});

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

// ---- OWNERSHIP STATUS
router.get("/api/cover-templates/:id/my-status", 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) return res.json({ owned: false });
  return res.json({ owned: true, download_url: row.download_url, purchase_id: row.id });
});

// ---- SECURE DOWNLOAD (redirect to file if owned)
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: signed URL via S3 helper (preferred in production)
  if (process.env.USE_SIGNED_URLS === "true") {
    const signed = await getSignedDownloadUrl(row.download_url); // pass storage key or URL
    return res.redirect(signed);
  }

  // Option B: redirect directly to CDN/public URL
  return res.redirect(row.download_url);
});

// ---- PURCHASE LOOKUP (for success auto-download 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);
});

// ---- CHECKOUT (create purchase first, then embed purchaseId in success URL)
router.post("/api/cover-templates/:id/checkout", authenticate, async (req: any, res) => {
  const tpl = await getCoverTemplate(req.params.id);
  if (!tpl || !tpl.is_active || tpl.approval_status !== "approved") return res.status(404).json({ error: "Not found" });

  const { customImageUrl } = req.body || {};
  const amount = parseInt(process.env.COVER_TEMPLATE_PRICE_CENTS || "1499", 10);
  const currency = (process.env.COVER_TEMPLATE_CURRENCY || "usd").toLowerCase();

  // 1) create pending purchase now
  const purchase = await createPurchase({
    user_id: req.user.id,
    template_id: tpl.id,
    custom_image_url: customImageUrl || null,
    amount_cents: amount,
    currency,
    stripe_session_id: null,
  });

  // 2) create checkout session with purchaseId in success URL & metadata
  const successUrl = `${process.env.APP_BASE_URL}/business-assets/templates/cover-dividers?success=true&purchase=${purchase.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: purchase.id }
  });

  // 3) store session id
  await db("cover_purchases").where({ id: purchase.id }).update({ stripe_session_id: session.id });

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

export default router;


Your webhook from earlier will set the download_url and mark status='paid'. If you store files on S3, set USE_SIGNED_URLS=true and configure the helper below.

2) Server: Stripe webhook (already done)

Keep your existing webhook (/api/stripe/webhook) that marks the purchase paid and sets download_url. If you want to validate the purchaseId, read it from session.metadata.purchaseId too.

3) Client: page logic (Buy Now = download if owned else checkout; auto-download on success)

In client/src/pages/BusinessAssets/CoverDividerTemplates.tsx (the gallery page you’re using), add:

3.1 Helper to “buy or download”
async function buyOrDownload(tplId: string, customImageUrl?: 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) {
      // owner → trigger secure download route (sets headers / redirects)
      window.location.href = `/api/cover-templates/${tplId}/download`;
      return;
    }
  }
  // 2) not owned → checkout
  const r = await fetch(`/api/cover-templates/${tplId}/checkout`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify({ customImageUrl: customImageUrl || null })
  });
  if (!r.ok) { toast.error("Could not start checkout"); return; }
  const { checkoutUrl } = await r.json();
  window.location.href = checkoutUrl;
}

3.2 Wire the lightbox button
<Button className="w-full" onClick={() => buyOrDownload(sel!.id)}>Buy Now</Button>

3.3 Auto-download after Stripe returns

Add this effect near the top of the component:

useEffect(() => {
  const sp = new URLSearchParams(window.location.search);
  if (sp.get("success") === "true" && sp.get("purchase")) {
    const purchaseId = sp.get("purchase")!;
    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) {
          // if using secure route
          window.location.href = j.download_url.startsWith("http")
            ? j.download_url
            : `/api/cover-templates/${j.template_id}/download`;
          return;
        }
      }
      if (tries < 12) setTimeout(poll, 1500);
      else toast.info("Payment received. Your download will appear in ‘My Purchases’ shortly.");
    };
    poll();
  }
}, []);

3.4 (Nice) Change button label to “Download” if owned
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) Optional: S3 signed URL helper (production-safe downloads)

If your download_url points to an S3 object key (e.g., s3://bucket/key.zip or you store the key), create:

server/storage/signedUrls.ts

// server/storage/signedUrls.ts
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: process.env.AWS_ACCESS_KEY_ID
    ? { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY! }
    : undefined,
});

// Accept either a raw key or a fully qualified "bucket:key" or "s3://bucket/key"
export async function getSignedDownloadUrl(raw: string, expiresSeconds = 60): Promise<string> {
  // Parse input
  let bucket = process.env.AWS_DOWNLOAD_BUCKET!;
  let key = raw;

  if (raw.startsWith("s3://")) {
    const [, rest] = raw.split("s3://");
    bucket = rest.split("/")[0];
    key = rest.substring(bucket.length + 1);
  } else if (raw.includes(":") && !raw.startsWith("http")) {
    // "bucket:key"
    const [b, k] = raw.split(":");
    bucket = b; key = k;
  }

  const cmd = new GetObjectCommand({ Bucket: bucket, Key: key });
  return await getSignedUrl(s3, cmd, { expiresIn: expiresSeconds });
}


ENV (if using S3)

USE_SIGNED_URLS=true
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=xxxxxxxx
AWS_SECRET_ACCESS_KEY=xxxxxxxx
AWS_DOWNLOAD_BUCKET=your-bucket-name


Then set cover_purchases.download_url to the key (e.g., my-templates/cov-minimal-bundle.zip). The /download route will sign and redirect.

5) “My Purchases” page (stub)

client/src/pages/Account/MyPurchases.tsx

import { useQuery } from "@tanstack/react-query";
import { DashboardTemplatePage } from "@/components/DashboardTemplatePage";
import { Button } from "@/components/ui/button";

type Purchase = {
  id: string; template_id: string; status: string; download_url?: string; created_at: string;
};

export default function MyPurchases() {
  const { data, isLoading } = useQuery({
    queryKey: ["my-purchases"],
    queryFn: async () => {
      const r = await fetch("/api/me/cover-purchases", { credentials: "include" });
      if (!r.ok) throw new Error("load");
      return r.json() as Promise<Purchase[]>;
    }
  });

  return (
    <DashboardTemplatePage title="My Purchases">
      <div className="rounded-xl border p-4">
        {isLoading ? "Loading…" : (data?.length ? (
          <div className="space-y-3">
            {data!.map(p=>(
              <div key={p.id} className="flex items-center justify-between border rounded p-3">
                <div className="text-sm">
                  <div className="font-medium">{p.template_id}</div>
                  <div className="text-muted-foreground text-xs">Purchased {new Date(p.created_at).toLocaleString()}</div>
                  <div className="text-xs">Status: <b>{p.status}</b></div>
                </div>
                <Button
                  disabled={p.status !== "paid"}
                  onClick={() => window.location.href = `/api/cover-templates/${p.template_id}/download`}
                >
                  Download
                </Button>
              </div>
            ))}
          </div>
        ) : (
          <div className="text-muted-foreground">No purchases yet.</div>
        ))}
      </div>
    </DashboardTemplatePage>
  );
}


Server route (list my purchases) — if you don’t already have it:

router.get("/api/me/cover-purchases", authenticate, async (req: any, res) => {
  const rows = await db("cover_purchases").where({ user_id: req.user.id }).orderBy("created_at","desc");
  res.json(rows);
});


App route

// in App.tsx
import MyPurchases from "@/pages/Account/MyPurchases";
<Route path="/account/purchases" element={<ProtectedRoute><MyPurchases /></ProtectedRoute>} />


Add a link in your account menu to /account/purchases.

6) QA script (fast)

Visit gallery → click a card → lightbox shows Cover + 3 Divider previews.

Click Buy Now (not owned) → Stripe → pay → redirect → auto-download begins.

Open the same template again → button now says Download (or at least immediately downloads).

Visit My Purchases → item listed → Download works.

Try logged-out → any secure endpoint returns 401; gallery still browsable but download blocked.