[object Object]

← back to NationalPaperHangers

Validate the plan tier on billing checkout so installers cannot self-upgrade for free

50ae349256620b5fe94c74343ee3848b2ca9da68 · 2026-05-19 07:59:20 -0700 · Steve

Files touched

Diff

commit 50ae349256620b5fe94c74343ee3848b2ca9da68
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 07:59:20 2026 -0700

    Validate the plan tier on billing checkout so installers cannot self-upgrade for free
---
 routes/admin.js | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/routes/admin.js b/routes/admin.js
index 873df3d..b8f4024 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -459,9 +459,21 @@ router.get('/billing', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+// Only these tiers are purchasable. 'basic' is the free default and is never
+// a checkout target. Without this guard the mocked checkout path writes the
+// raw body `tier` straight to installers.tier — a logged-in installer could
+// POST tier=enterprise and self-upgrade for free in mock mode.
+const PURCHASABLE_TIERS = new Set(['pro', 'signature', 'enterprise']);
+const VALID_CADENCES = new Set(['month', 'year']);
+
 router.post('/billing/checkout', async (req, res, next) => {
   try {
-    const { tier, cadence } = req.body;
+    const tier = String(req.body.tier || '').toLowerCase();
+    const cadence = String(req.body.cadence || 'month').toLowerCase();
+    if (!PURCHASABLE_TIERS.has(tier) || !VALID_CADENCES.has(cadence)) {
+      req.session.flash = { error: 'Pick a valid plan to continue.' };
+      return res.redirect('/admin/billing');
+    }
     const result = await stripe.createCheckoutSession({
       installer: req.installer,
       tier, cadence,

← 1e77ada Actually limit verified-brand chips to 3 — LIMIT next to arr  ·  back to NationalPaperHangers  ·  Constrain profile hero_url to the installer's own uploads, n 258f66a →