diff --git a/dist/index.js b/dist/index.js index 07587b8..4a41bd8 100644 --- a/dist/index.js +++ b/dist/index.js @@ -196,6 +196,10 @@ app.get("/terms", (_req, res) => { res.setHeader('Cache-Control', 'public, max-age=86400'); res.sendFile(path.join(__dirname, "../public/terms.html")); }); +app.get("/change-email", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.sendFile(path.join(__dirname, "../public/change-email.html")); +}); app.get("/status", (_req, res) => { res.setHeader("Cache-Control", "public, max-age=60"); res.sendFile(path.join(__dirname, "../public/status.html")); diff --git a/dist/routes/billing.js b/dist/routes/billing.js index e346f4f..0c59405 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -1,6 +1,6 @@ import { Router } from "express"; import Stripe from "stripe"; -import { createProKey, revokeByCustomer } from "../services/keys.js"; +import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js"; import logger from "../services/logger.js"; function escapeHtml(s) { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); @@ -18,6 +18,27 @@ function getStripe() { const router = Router(); // Track provisioned session IDs to prevent duplicate key creation const provisionedSessions = new Set(); +const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; +// Returns true if the given Stripe subscription contains a DocFast product. +// Used to filter webhook events — this Stripe account is shared with other projects. +async function isDocFastSubscription(subscriptionId) { + try { + const sub = await getStripe().subscriptions.retrieve(subscriptionId, { + expand: ["items.data.price.product"], + }); + return sub.items.data.some((item) => { + const price = item.price; + const productId = typeof price?.product === "string" + ? price.product + : price?.product?.id; + return productId === DOCFAST_PRODUCT_ID; + }); + } + catch (err) { + logger.error({ err, subscriptionId }, "isDocFastSubscription: failed to retrieve subscription"); + return false; + } +} // Create a Stripe Checkout session for Pro subscription router.post("/checkout", async (_req, res) => { try { @@ -114,7 +135,6 @@ router.post("/webhook", async (req, res) => { const customerId = session.customer; const email = session.customer_details?.email; // Filter by product — this Stripe account is shared with other projects - const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; try { const fullSession = await getStripe().checkout.sessions.retrieve(session.id, { expand: ["line_items"], @@ -143,11 +163,44 @@ router.post("/webhook", async (req, res) => { logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key"); break; } + case "customer.subscription.updated": { + const sub = event.data.object; + const customerId = sub.customer; + const shouldDowngrade = sub.status === "canceled" || + sub.status === "past_due" || + sub.status === "unpaid" || + sub.cancel_at_period_end === true; + if (shouldDowngrade) { + if (!(await isDocFastSubscription(sub.id))) { + logger.info({ subscriptionId: sub.id }, "customer.subscription.updated: ignoring event for different product"); + break; + } + await downgradeByCustomer(customerId); + logger.info({ customerId, status: sub.status, cancelAtPeriodEnd: sub.cancel_at_period_end }, "customer.subscription.updated: downgraded key to free tier"); + } + break; + } case "customer.subscription.deleted": { const sub = event.data.object; const customerId = sub.customer; - await revokeByCustomer(customerId); - logger.info({ customerId }, "Subscription cancelled, key revoked"); + if (!(await isDocFastSubscription(sub.id))) { + logger.info({ subscriptionId: sub.id }, "customer.subscription.deleted: ignoring event for different product"); + break; + } + await downgradeByCustomer(customerId); + logger.info({ customerId }, "customer.subscription.deleted: downgraded key to free tier"); + break; + } + case "customer.updated": { + const customer = event.data.object; + const customerId = customer.id; + const newEmail = customer.email; + if (customerId && newEmail) { + const updated = await updateEmailByCustomer(customerId, newEmail); + if (updated) { + logger.info({ customerId, newEmail }, "Customer email synced from Stripe"); + } + } break; } default: diff --git a/dist/services/keys.js b/dist/services/keys.js index 2424060..6b751fc 100644 --- a/dist/services/keys.js +++ b/dist/services/keys.js @@ -77,12 +77,11 @@ export async function createProKey(email, stripeCustomerId) { keysCache.push(entry); return entry; } -export async function revokeByCustomer(stripeCustomerId) { - const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId); - if (idx >= 0) { - const key = keysCache[idx].key; - keysCache.splice(idx, 1); - await pool.query("DELETE FROM api_keys WHERE key = $1", [key]); +export async function downgradeByCustomer(stripeCustomerId) { + const entry = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId); + if (entry) { + entry.tier = "free"; + await pool.query("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]); return true; } return false; @@ -98,3 +97,11 @@ export async function updateKeyEmail(apiKey, newEmail) { await pool.query("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]); return true; } +export async function updateEmailByCustomer(stripeCustomerId, newEmail) { + const entry = keysCache.find(k => k.stripeCustomerId === stripeCustomerId); + if (!entry) + return false; + entry.email = newEmail; + await pool.query("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]); + return true; +} diff --git a/public/impressum.html b/public/impressum.html index d762d6b..7c13fc2 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -20,7 +20,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -44,8 +44,6 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } - -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
@@ -98,8 +96,9 @@ footer .container { display: flex; justify-content: space-between; align-items: - +Your data never leaves the EU • GDPR Compliant • Hetzner Germany (Nuremberg)