Fix: read Stripe price IDs from NEXT_PUBLIC env vars

This commit is contained in:
Nolan Kovacs 2026-04-01 20:43:05 -04:00
parent 607fa059b9
commit 8ac25ae633
6 changed files with 289 additions and 12 deletions

34
app/api/checkout/route.ts Normal file
View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Map price IDs to human-readable tier names
const TIER_NAMES: Record<string, string> = {
[process.env.STRIPE_PRICE_WEEKLY!]: "Weekly",
[process.env.STRIPE_PRICE_MONTHLY!]: "Monthly",
[process.env.STRIPE_PRICE_ANNUAL!]: "Annual",
[process.env.STRIPE_PRICE_LIFETIME!]: "Lifetime",
};
export async function POST(req: NextRequest) {
const { priceId } = await req.json();
if (!priceId || !TIER_NAMES[priceId]) {
return NextResponse.json({ error: "Invalid price" }, { status: 400 });
}
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/uit#pricing`,
metadata: { tier: TIER_NAMES[priceId] },
invoice_creation: { enabled: true },
custom_text: {
submit: { message: "Your license key will be emailed to you immediately after purchase." },
},
});
return NextResponse.json({ url: session.url });
}

122
app/api/webhook/route.ts Normal file
View File

@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import nodemailer from "nodemailer";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const mailer = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER, // nolankovacs@verimundsolutions.com Gmail address
pass: process.env.SMTP_PASS, // Gmail App Password
},
});
// Tier → expiry days (null = lifetime)
const TIER_EXPIRY: Record<string, number | null> = {
Weekly: 7,
Monthly: 30,
Annual: 365,
Lifetime: null,
};
async function generateLicense(tier: string, email: string): Promise<string> {
const res = await fetch(`${process.env.API_URL}/admin/generate-license`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Admin-Key": process.env.ADMIN_KEY!,
},
body: JSON.stringify({
tier: tier.toLowerCase(),
email,
days_valid: TIER_EXPIRY[tier],
channel: "stable",
source: "stripe",
}),
});
if (!res.ok) throw new Error(`License generation failed: ${res.status}`);
const data = await res.json();
return data.license_key;
}
async function sendLicenseEmail(email: string, tier: string, licenseKey: string) {
const expiryText = TIER_EXPIRY[tier] === null
? "Your license never expires."
: `Your license is valid for ${TIER_EXPIRY[tier]} days from today.`;
await mailer.sendMail({
from: `"Verimund Solutions" <noreply@verimundsolutions.com>`,
to: email,
subject: `Your UIT ${tier} License Key`,
html: `
<div style="background:#0a0a0a;color:#ffffff;font-family:Arial,sans-serif;padding:40px;max-width:520px;margin:0 auto;">
<p style="color:#4ade80;font-size:11px;font-weight:600;letter-spacing:0.2em;text-transform:uppercase;margin-bottom:24px;">
Verimund Solutions
</p>
<h1 style="font-size:24px;font-weight:600;margin-bottom:8px;">Your license key is ready.</h1>
<p style="color:#888888;font-size:14px;margin-bottom:32px;">
Thank you for purchasing UIT ${tier}. ${expiryText}
</p>
<div style="background:#111111;border:1px solid #222222;border-radius:4px;padding:20px;margin-bottom:32px;">
<p style="color:#888888;font-size:11px;text-transform:uppercase;letter-spacing:0.15em;margin-bottom:8px;">License Key</p>
<p style="font-family:monospace;font-size:18px;font-weight:600;letter-spacing:0.05em;color:#ffffff;word-break:break-all;">
${licenseKey}
</p>
</div>
<p style="color:#888888;font-size:13px;line-height:1.7;margin-bottom:16px;">
To activate, open UIT and enter this key when prompted. The license will bind to your machine on first use.
</p>
<p style="color:#888888;font-size:13px;line-height:1.7;margin-bottom:32px;">
If you need to transfer your license to a new machine, contact us at
<a href="mailto:support@verimundsolutions.com" style="color:#ffffff;">support@verimundsolutions.com</a>.
</p>
<hr style="border:none;border-top:1px solid #222222;margin-bottom:24px;" />
<p style="color:#3a3a3a;font-size:11px;">
© ${new Date().getFullYear()} Verimund Solutions. This email was sent to ${email} because you purchased a UIT license.
</p>
</div>
`,
});
}
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
const tier = session.metadata?.tier;
if (!email || !tier) {
console.error("[WEBHOOK] Missing email or tier", { email, tier });
return NextResponse.json({ error: "Missing data" }, { status: 400 });
}
try {
const licenseKey = await generateLicense(tier, email);
await sendLicenseEmail(email, tier, licenseKey);
console.log(`[WEBHOOK] License issued: ${tier}${email}`);
} catch (err) {
console.error("[WEBHOOK] Failed to issue license:", err);
// Return 200 so Stripe doesn't retry — log the failure for manual follow-up
// TODO: add to a failed_deliveries table for retry
}
}
return NextResponse.json({ received: true });
}

View File

@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function PurchaseSuccess() {
return (
<section className="min-h-screen flex items-center justify-center px-6">
<motion.div
className="max-w-md w-full text-center"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
<div
className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6"
style={{ background: "#4ade8018", border: "1px solid #4ade8030" }}
>
<span style={{ color: "#4ade80", fontSize: "20px" }}></span>
</div>
<h1 className="text-3xl font-semibold mb-3" style={{ letterSpacing: "-0.02em" }}>
Purchase complete.
</h1>
<p className="text-sm mb-2" style={{ color: "#888888", lineHeight: 1.7 }}>
Your license key is on its way. Check your email delivery is typically within a minute.
</p>
<p className="text-sm mb-10" style={{ color: "#888888", lineHeight: 1.7 }}>
If you don&apos;t see it, check your spam folder or contact{" "}
<a
href="mailto:support@verimundsolutions.com"
style={{ color: "#ffffff" }}
>
support@verimundsolutions.com
</a>.
</p>
<Link
href="/uit"
className="inline-block px-6 py-3 text-sm font-medium rounded-sm border transition-all duration-200"
style={{ border: "1px solid #222222", color: "#888888" }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#ffffff";
e.currentTarget.style.color = "#ffffff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#222222";
e.currentTarget.style.color = "#888888";
}}
>
Back to UIT
</Link>
</motion.div>
</section>
);
}

View File

@ -40,7 +40,7 @@ const pricingTiers = [
sub: null,
discount: null,
popular: false,
priceId: "STRIPE_PRICE_ID_WEEKLY",
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_WEEKLY ?? "",
},
{
name: "Monthly",
@ -49,7 +49,7 @@ const pricingTiers = [
sub: null,
discount: null,
popular: false,
priceId: "STRIPE_PRICE_ID_MONTHLY",
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_MONTHLY ?? "",
},
{
name: "Annual",
@ -58,7 +58,7 @@ const pricingTiers = [
sub: "$12.42 / mo",
discount: "Save 35%",
popular: true,
priceId: "STRIPE_PRICE_ID_ANNUAL",
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ANNUAL ?? "",
},
{
name: "Lifetime",
@ -67,15 +67,27 @@ const pricingTiers = [
sub: null,
discount: null,
popular: false,
priceId: "STRIPE_PRICE_ID_LIFETIME",
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME ?? "",
},
];
export default function UIT() {
const handleBuy = (priceId: string) => {
// Stripe Checkout — wired once live keys are in place
console.log("Checkout:", priceId);
alert("Purchase flow coming soon. Check back shortly.");
const handleBuy = async (priceId: string) => {
if (priceId.startsWith("STRIPE_PRICE_ID")) {
alert("Purchase flow coming soon. Check back shortly.");
return;
}
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const data = await res.json();
if (data.url) window.location.href = data.url;
} catch {
alert("Something went wrong. Please try again or contact support@verimundsolutions.com.");
}
};
return (

55
package-lock.json generated
View File

@ -8,14 +8,18 @@
"name": "website",
"version": "0.1.0",
"dependencies": {
"@stripe/stripe-js": "^9.0.1",
"framer-motion": "^12.38.0",
"next": "16.2.2",
"nodemailer": "^8.0.4",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"stripe": "^21.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^7.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@ -1234,6 +1238,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stripe/stripe-js": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.0.1.tgz",
"integrity": "sha512-un0URSosrW7wNr7xZ5iI2mC9mdeXZ3KERoVlA2RdmeLXYxHUPXq0yHzir2n/MtyXXEdSaELtz4WXGS6dzPEeKA==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -1550,12 +1563,22 @@
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -5134,6 +5157,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -6067,6 +6099,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "21.0.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-21.0.1.tgz",
"integrity": "sha512-ocv0j7dWttswDWV2XL/kb6+yiLpDXNXL3RQAOB5OB2kr49z0cEatdQc12+zP/j5nrXk6rAsT4N3y/NUvBbK7Pw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@ -6395,7 +6444,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View File

@ -9,14 +9,18 @@
"lint": "eslint"
},
"dependencies": {
"@stripe/stripe-js": "^9.0.1",
"framer-motion": "^12.38.0",
"next": "16.2.2",
"nodemailer": "^8.0.4",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"stripe": "^21.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^7.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",