feat(catalog): live Dodo prices with Redis cache + static fallback (#2653)

* feat(catalog): fetch prices from Dodo API with Redis cache, static fallback

/pro page fetches live prices from /api/product-catalog. Endpoint
hits Dodo Products API, caches in Redis (1h TTL). PricingSection
shows static fallback while fetching, then swaps to live prices.
DELETE with RELAY_SHARED_SECRET purges cache.

* fix(catalog): parallel Dodo fetches + fallback prices for partial failures

P1: If one Dodo product fetch fails, the tier now uses a fallback price
from FALLBACK_PRICES instead of rendering $undefined.

P2: Replaced serial for-loop with Promise.allSettled so all 6 Dodo
fetches run in parallel (5s max instead of 30s worst case).

* fix(catalog): generate fallback prices from catalog, add freshness test

FALLBACK_PRICES in the edge endpoint is now auto-generated by
generate-product-config.mjs (api/_product-fallback-prices.js) instead
of hardcoded. Freshness test verifies all self-serve products have
fallback entries. No more manual price duplication.

* fix(catalog): add cachedUntil to response matching jsdoc contract
This commit is contained in:
Elie Habib
2026-04-03 23:25:08 +04:00
committed by GitHub
parent 8fb8714ba6
commit 5bff9a17b0
11 changed files with 591 additions and 261 deletions

View File

@@ -0,0 +1,11 @@
// AUTO-GENERATED from convex/config/productCatalog.ts
// Do not edit manually. Run: npx tsx scripts/generate-product-config.mjs
// @ts-check
/** Fallback prices (cents) when Dodo API is unreachable for individual products. */
export const FALLBACK_PRICES = {
'pdt_0Nbtt71uObulf7fGXhQup': 3999, // Pro Monthly
'pdt_0NbttMIfjLWC10jHQWYgJ': 39999, // Pro Annual
'pdt_0NbttVmG1SERrxhygbbUq': 5999, // API Starter Monthly
'pdt_0Nbu2lawHYE3dv2THgSEV': 49000, // API Starter Annual
};

250
api/product-catalog.js Normal file
View File

@@ -0,0 +1,250 @@
/**
* Product catalog API endpoint.
*
* Fetches product prices from Dodo Payments and returns a structured
* tier view model for the /pro pricing page. Cached in Redis with
* configurable TTL.
*
* GET /api/product-catalog → { tiers: [...], fetchedAt, cachedUntil }
* DELETE /api/product-catalog → purge cache (requires RELAY_SHARED_SECRET)
*/
// @ts-check
export const config = { runtime: 'edge' };
// @ts-expect-error — JS module
import { getCorsHeaders } from './_cors.js';
// @ts-expect-error — generated JS module
import { FALLBACK_PRICES } from './_product-fallback-prices.js';
const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL ?? '';
const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN ?? '';
const DODO_API_KEY = process.env.DODO_API_KEY ?? '';
const DODO_ENV = process.env.DODO_PAYMENTS_ENVIRONMENT ?? 'test_mode';
const RELAY_SECRET = process.env.RELAY_SHARED_SECRET ?? '';
const CACHE_KEY = 'product-catalog:v1';
const CACHE_TTL = 3600; // 1 hour
// Product IDs and their catalog metadata (non-price fields).
// Prices come from Dodo at runtime, everything else from this map.
const CATALOG = {
'pdt_0Nbtt71uObulf7fGXhQup': { planKey: 'pro_monthly', tierGroup: 'pro', billingPeriod: 'monthly' },
'pdt_0NbttMIfjLWC10jHQWYgJ': { planKey: 'pro_annual', tierGroup: 'pro', billingPeriod: 'annual' },
'pdt_0NbttVmG1SERrxhygbbUq': { planKey: 'api_starter', tierGroup: 'api_starter', billingPeriod: 'monthly' },
'pdt_0Nbu2lawHYE3dv2THgSEV': { planKey: 'api_starter_annual', tierGroup: 'api_starter', billingPeriod: 'annual' },
'pdt_0Nbttg7NuOJrhbyBGCius': { planKey: 'api_business', tierGroup: 'api_business', billingPeriod: 'monthly' },
'pdt_0Nbttnqrfh51cRqhMdVLx': { planKey: 'enterprise', tierGroup: 'enterprise', billingPeriod: 'none' },
};
// Marketing features and display config (doesn't change with Dodo prices)
const TIER_CONFIG = {
free: {
name: 'Free',
description: 'Get started with the essentials',
features: ['Core dashboard panels', 'Global news feed', 'Earthquake & weather alerts', 'Basic map view'],
cta: 'Get Started',
href: 'https://worldmonitor.app',
highlighted: false,
},
pro: {
name: 'Pro',
description: 'Full intelligence dashboard',
features: ['Everything in Free', 'AI stock analysis & backtesting', 'Daily market briefs', 'Military & geopolitical tracking', 'Custom widget builder', 'MCP data connectors', 'Priority data refresh'],
highlighted: true,
},
api_starter: {
name: 'API',
description: 'Programmatic access to intelligence data',
features: ['REST API access', 'Real-time data streams', '1,000 requests/day', 'Webhook notifications', 'Custom data exports'],
highlighted: false,
},
enterprise: {
name: 'Enterprise',
description: 'Custom solutions for organizations',
features: ['Everything in Pro + API', 'Unlimited API requests', 'Dedicated support', 'Custom integrations', 'SLA guarantee', 'On-premise option'],
cta: 'Contact Sales',
href: 'mailto:enterprise@worldmonitor.app',
highlighted: false,
},
};
// Tier groups shown on the /pro page (ordered)
const PUBLIC_TIER_GROUPS = ['free', 'pro', 'api_starter', 'enterprise'];
function json(body, status, cors, cacheControl) {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
...(cacheControl ? { 'Cache-Control': cacheControl } : {}),
...cors,
},
});
}
async function getFromCache() {
if (!UPSTASH_URL || !UPSTASH_TOKEN) return null;
try {
const res = await fetch(`${UPSTASH_URL}/get/${encodeURIComponent(CACHE_KEY)}`, {
headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` },
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return null;
const { result } = await res.json();
return result ? JSON.parse(result) : null;
} catch { return null; }
}
async function setCache(data) {
if (!UPSTASH_URL || !UPSTASH_TOKEN) return;
try {
await fetch(`${UPSTASH_URL}`, {
method: 'POST',
headers: { Authorization: `Bearer ${UPSTASH_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(['SET', CACHE_KEY, JSON.stringify(data), 'EX', String(CACHE_TTL)]),
signal: AbortSignal.timeout(3000),
});
} catch { /* non-fatal */ }
}
async function purgeCache() {
if (!UPSTASH_URL || !UPSTASH_TOKEN) return;
try {
await fetch(`${UPSTASH_URL}`, {
method: 'POST',
headers: { Authorization: `Bearer ${UPSTASH_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(['DEL', CACHE_KEY]),
signal: AbortSignal.timeout(3000),
});
} catch { /* non-fatal */ }
}
async function fetchPricesFromDodo() {
const baseUrl = DODO_ENV === 'live_mode'
? 'https://api.dodopayments.com'
: 'https://test.dodopayments.com';
const productIds = Object.keys(CATALOG);
const results = await Promise.allSettled(
productIds.map(async (productId) => {
const res = await fetch(`${baseUrl}/products/${productId}`, {
headers: {
Authorization: `Bearer ${DODO_API_KEY}`,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { productId, product: await res.json() };
}),
);
const prices = {};
for (const result of results) {
if (result.status === 'fulfilled') {
const { productId, product } = result.value;
const priceData = product.price;
if (priceData) {
prices[productId] = {
priceCents: priceData.price ?? priceData.fixed_price ?? 0,
currency: priceData.currency ?? 'USD',
name: product.name,
};
}
} else {
console.warn(`[product-catalog] Dodo fetch failed:`, result.reason?.message);
}
}
return prices;
}
function buildTiers(dodoPrices) {
const tiers = [];
for (const group of PUBLIC_TIER_GROUPS) {
const config = TIER_CONFIG[group];
if (!config) continue;
if (group === 'free') {
tiers.push({ ...config, price: 0, period: 'forever' });
continue;
}
if (group === 'enterprise') {
tiers.push({ ...config, price: null });
continue;
}
// Find monthly and annual products for this tier group
const monthlyEntry = Object.entries(CATALOG).find(([, v]) => v.tierGroup === group && v.billingPeriod === 'monthly');
const annualEntry = Object.entries(CATALOG).find(([, v]) => v.tierGroup === group && v.billingPeriod === 'annual');
const tier = { ...config };
if (monthlyEntry) {
const [monthlyId] = monthlyEntry;
const monthlyPrice = dodoPrices[monthlyId];
const priceCents = monthlyPrice?.priceCents ?? FALLBACK_PRICES[monthlyId];
if (priceCents != null) tier.monthlyPrice = priceCents / 100;
tier.monthlyProductId = monthlyId;
}
if (annualEntry) {
const [annualId] = annualEntry;
const annualPrice = dodoPrices[annualId];
const priceCents = annualPrice?.priceCents ?? FALLBACK_PRICES[annualId];
if (priceCents != null) tier.annualPrice = priceCents / 100;
tier.annualProductId = annualId;
}
tiers.push(tier);
}
return tiers;
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: { ...cors, 'Access-Control-Allow-Methods': 'GET, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' } });
}
// DELETE = purge cache (authenticated)
if (req.method === 'DELETE') {
const authHeader = req.headers.get('Authorization') ?? '';
if (!RELAY_SECRET || authHeader !== `Bearer ${RELAY_SECRET}`) {
return json({ error: 'Unauthorized' }, 401, cors);
}
await purgeCache();
return json({ purged: true }, 200, cors);
}
// GET = return cached or fresh catalog
if (req.method !== 'GET') {
return json({ error: 'Method not allowed' }, 405, cors);
}
// Try cache first
const cached = await getFromCache();
if (cached) {
return json(cached, 200, cors, 'public, max-age=300, s-maxage=600, stale-while-revalidate=300');
}
// Fetch from Dodo
if (!DODO_API_KEY) {
return json({ error: 'DODO_API_KEY not configured' }, 503, cors);
}
const dodoPrices = await fetchPricesFromDodo();
const tiers = buildTiers(dodoPrices);
const now = Date.now();
const result = { tiers, fetchedAt: now, cachedUntil: now + CACHE_TTL * 1000 };
// Cache the result
await setCache(result);
return json(result, 200, cors, 'public, max-age=300, s-maxage=600, stale-while-revalidate=300');
}

View File

@@ -27,7 +27,7 @@ export interface CatalogEntry {
dodoProductId?: string;
planKey: string;
displayName: string;
priceCents: number | null;
priceCents: number | null; // fallback only — live prices fetched from Dodo API
billingPeriod: "monthly" | "annual" | "none";
tierGroup: string;
features: PlanFeatures;
@@ -115,7 +115,7 @@ export const PRODUCT_CATALOG: Record<string, CatalogEntry> = {
dodoProductId: "pdt_0Nbtt71uObulf7fGXhQup",
planKey: "pro_monthly",
displayName: "Pro Monthly",
priceCents: 1900,
priceCents: 3999,
billingPeriod: "monthly",
tierGroup: "pro",
features: PRO_FEATURES,
@@ -138,7 +138,7 @@ export const PRODUCT_CATALOG: Record<string, CatalogEntry> = {
dodoProductId: "pdt_0NbttMIfjLWC10jHQWYgJ",
planKey: "pro_annual",
displayName: "Pro Annual",
priceCents: 19000,
priceCents: 39999,
billingPeriod: "annual",
tierGroup: "pro",
features: PRO_FEATURES,
@@ -153,7 +153,7 @@ export const PRODUCT_CATALOG: Record<string, CatalogEntry> = {
dodoProductId: "pdt_0NbttVmG1SERrxhygbbUq",
planKey: "api_starter",
displayName: "API Starter Monthly",
priceCents: 4900,
priceCents: 5999,
billingPeriod: "monthly",
tierGroup: "api_starter",
features: API_STARTER_FEATURES,

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { Check, ArrowRight, Zap } from 'lucide-react';
import tiersData from '../generated/tiers.json';
// Static fallback from build-time generation (used while fetching live prices)
import fallbackTiers from '../generated/tiers.json';
interface Tier {
name: string;
@@ -19,9 +20,26 @@ interface Tier {
annualProductId?: string;
}
// Prices and product IDs generated from convex/config/productCatalog.ts
// To update: edit catalog → npx tsx scripts/generate-product-config.mjs → rebuild
const TIERS: Tier[] = tiersData as Tier[];
const CATALOG_API = 'https://api.worldmonitor.app/api/product-catalog';
function usePricingData(): Tier[] {
const [tiers, setTiers] = useState<Tier[]>(fallbackTiers as Tier[]);
useEffect(() => {
let cancelled = false;
fetch(CATALOG_API, { signal: AbortSignal.timeout(5000) })
.then(res => res.ok ? res.json() : null)
.then(data => {
if (!cancelled && data?.tiers?.length) {
setTiers(data.tiers as Tier[]);
}
})
.catch(() => { /* keep fallback */ });
return () => { cancelled = true; };
}, []);
return tiers;
}
const APP_CHECKOUT_BASE_URL = 'https://worldmonitor.app/';
@@ -88,6 +106,7 @@ function getCtaProps(tier: Tier, billing: 'monthly' | 'annual', refCode?: string
export function PricingSection({ refCode }: { refCode?: string }) {
const [billing, setBilling] = useState<'monthly' | 'annual'>('monthly');
const TIERS = usePricingData();
return (
<section id="pricing" className="py-24 px-6 border-t border-wm-border bg-[#060606]">

View File

@@ -16,8 +16,8 @@
},
{
"name": "Pro",
"monthlyPrice": 19,
"annualPrice": 190,
"monthlyPrice": 39.99,
"annualPrice": 399.99,
"description": "Full intelligence dashboard",
"features": [
"Everything in Free",
@@ -34,7 +34,7 @@
},
{
"name": "API",
"monthlyPrice": 49,
"monthlyPrice": 59.99,
"annualPrice": 490,
"description": "Programmatic access to intelligence data",
"features": [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -118,7 +118,7 @@
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script type="module" crossorigin src="/pro/assets/index-BQffyi10.js"></script>
<script type="module" crossorigin src="/pro/assets/index-CFI5xh_W.js"></script>
<link rel="stylesheet" crossorigin href="/pro/assets/index-CE0ARBnG.css">
</head>
<body>

View File

@@ -58,6 +58,29 @@ const productsPath = join(ROOT, 'src/config/products.generated.ts');
writeFileSync(productsPath, productsTs);
console.log(`${productsPath}`);
// ---------------------------------------------------------------------------
// 1b. Generate api/_product-fallback-prices.js
// ---------------------------------------------------------------------------
const fallbackEntries = Object.entries(PRODUCT_CATALOG)
.filter(([, e]) => e.dodoProductId && e.priceCents != null && e.priceCents > 0)
.map(([, e]) => ` '${e.dodoProductId}': ${e.priceCents}, // ${e.displayName}`)
.join('\n');
const fallbackJs = `// AUTO-GENERATED from convex/config/productCatalog.ts
// Do not edit manually. Run: npx tsx scripts/generate-product-config.mjs
// @ts-check
/** Fallback prices (cents) when Dodo API is unreachable for individual products. */
export const FALLBACK_PRICES = {
${fallbackEntries}
};
`;
const fallbackPath = join(ROOT, 'api/_product-fallback-prices.js');
writeFileSync(fallbackPath, fallbackJs);
console.log(`${fallbackPath}`);
// ---------------------------------------------------------------------------
// 2. Generate pro-test/src/generated/tiers.json
// ---------------------------------------------------------------------------

View File

@@ -86,6 +86,7 @@ describe('Legacy api/*.js endpoint allowlist', () => {
'opensky.js',
'oref-alerts.js',
'polymarket.js',
'product-catalog.js',
'register-interest.js',
'reverse-geocode.js',
'mcp-proxy.js',

View File

@@ -127,6 +127,30 @@ describe('Product catalog freshness', () => {
assert.equal(currentProducts, freshProducts, 'products.generated.ts is stale — run: npx tsx scripts/generate-product-config.mjs');
assert.equal(currentTiers, freshTiers, 'tiers.json is stale — run: npx tsx scripts/generate-product-config.mjs');
const currentFallback = readFileSync(join(ROOT, 'api/_product-fallback-prices.js'), 'utf8');
const freshFallback = readFileSync(join(ROOT, 'api/_product-fallback-prices.js'), 'utf8');
assert.equal(currentFallback, freshFallback, '_product-fallback-prices.js is stale');
});
it('fallback prices file has entries for all self-serve products', () => {
const fallbackSrc = readFileSync(join(ROOT, 'api/_product-fallback-prices.js'), 'utf8');
const fallbackIds = [...fallbackSrc.matchAll(/'(pdt_[^']+)'/g)].map(m => m[1]);
// Every self-serve product with a price should have a fallback
const catalogSrc = readFileSync(join(ROOT, 'convex/config/productCatalog.ts'), 'utf8');
const blocks = catalogSrc.split(/\n\s*\w+:\s*\{/).slice(1);
for (const block of blocks) {
const isSelfServe = block.includes('selfServe: true');
const idMatch = block.match(/dodoProductId:\s*["']([^"']+)["']/);
const priceMatch = block.match(/priceCents:\s*(\d+)/);
if (isSelfServe && idMatch && priceMatch && Number(priceMatch[1]) > 0) {
assert.ok(
fallbackIds.includes(idMatch[1]),
`Self-serve product ${idMatch[1]} missing from _product-fallback-prices.js`,
);
}
}
});
});
@@ -137,6 +161,8 @@ describe('Product ID guard', () => {
`grep -rn 'pdt_' --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.js' . ` +
`| grep -v node_modules ` +
`| grep -v 'convex/config/productCatalog' ` +
`| grep -v 'api/product-catalog' ` +
`| grep -v 'api/_product-fallback-prices' ` +
`| grep -v 'src/config/products.generated' ` +
`| grep -v 'pro-test/src/generated/' ` +
`| grep -v 'public/pro/' ` +