refactor(payments): product catalog single source of truth + API annual + live IDs (#2649)

* fix(payments): add API Starter annual product, simplify /pro page

- Added API Starter Annual product ID (pdt_0Nbu2lawHYE3dv2THgSEV)
- API card now supports annual toggle matching Pro card
- Removed Enterprise and API Business from /pro page (not self-serve)
- Added api_starter_annual to entitlements and seedProductPlans
- Rebuilt /pro bundle

Requires npx convex deploy + seedProductPlans after merge.

* fix(pro): restore Enterprise card, fix API to 1,000 requests/day

Enterprise card back with "Contact Sales" mailto (no Dodo link, no
price). API Starter feature list corrected from 10,000 to 1,000
requests/day.

* refactor(payments): single source of truth product catalog

Canonical catalog in convex/config/productCatalog.ts owns all product
IDs, prices, features, and marketing copy. Build script generates
src/config/products.generated.ts and pro-test/src/generated/tiers.json.

To update prices: edit catalog, run generator, rebuild /pro, deploy Convex + seed.

* test(catalog): add bidirectional reverse assertions

- every currentForCheckout catalog entry must appear in products.generated.ts
- every publicVisible tier group must appear in tiers.json

* fix(payments): restore .unique() for productPlans lookup

Accidentally changed to .first() when adding legacy fallback. Duplicates
should fail loudly, not silently pick one row.

* fix(catalog): restore billing-distinct displayNames (Pro Monthly, API Starter Monthly)

displayName is used by seedProductPlans → billing UI. Collapsing to
marketing names ("Pro") lost the monthly/annual distinction. Tests
expect "Pro Monthly". Marketing /pro page unaffected (generator uses
tier group names).
This commit is contained in:
Elie Habib
2026-04-03 22:44:03 +04:00
committed by GitHub
parent df18d2241e
commit 8fb8714ba6
13 changed files with 735 additions and 212 deletions

View File

@@ -0,0 +1,282 @@
/**
* Canonical product catalog — single source of truth.
*
* All product IDs, prices, plan features, and marketing copy live here.
* Convex server functions import directly. Dashboard and /pro page consume
* auto-generated files produced by scripts/generate-product-config.mjs.
*
* To update prices or products:
* 1. Edit this file
* 2. Run: npx tsx scripts/generate-product-config.mjs
* 3. Commit generated files
* 4. Rebuild /pro: cd pro-test && npm run build
* 5. Deploy Convex: npx convex deploy
* 6. Re-seed plans: npx convex run payments/seedProductPlans:seedProductPlans
*/
export type PlanFeatures = {
tier: number;
maxDashboards: number;
apiAccess: boolean;
apiRateLimit: number;
prioritySupport: boolean;
exportFormats: string[];
};
export interface CatalogEntry {
dodoProductId?: string;
planKey: string;
displayName: string;
priceCents: number | null;
billingPeriod: "monthly" | "annual" | "none";
tierGroup: string;
features: PlanFeatures;
marketingFeatures: string[];
selfServe: boolean;
highlighted: boolean;
currentForCheckout: boolean;
publicVisible: boolean;
}
// ---------------------------------------------------------------------------
// Shared feature sets (avoids duplication across billing variants)
// ---------------------------------------------------------------------------
const FREE_FEATURES: PlanFeatures = {
tier: 0,
maxDashboards: 3,
apiAccess: false,
apiRateLimit: 0,
prioritySupport: false,
exportFormats: ["csv"],
};
const PRO_FEATURES: PlanFeatures = {
tier: 1,
maxDashboards: 10,
apiAccess: false,
apiRateLimit: 0,
prioritySupport: false,
exportFormats: ["csv", "pdf"],
};
const API_STARTER_FEATURES: PlanFeatures = {
tier: 2,
maxDashboards: 25,
apiAccess: true,
apiRateLimit: 60,
prioritySupport: false,
exportFormats: ["csv", "pdf", "json"],
};
const API_BUSINESS_FEATURES: PlanFeatures = {
tier: 2,
maxDashboards: 100,
apiAccess: true,
apiRateLimit: 300,
prioritySupport: true,
exportFormats: ["csv", "pdf", "json", "xlsx"],
};
const ENTERPRISE_FEATURES: PlanFeatures = {
tier: 3,
maxDashboards: -1,
apiAccess: true,
apiRateLimit: 1000,
prioritySupport: true,
exportFormats: ["csv", "pdf", "json", "xlsx", "api-stream"],
};
// ---------------------------------------------------------------------------
// The Catalog
// ---------------------------------------------------------------------------
export const PRODUCT_CATALOG: Record<string, CatalogEntry> = {
free: {
planKey: "free",
displayName: "Free",
priceCents: 0,
billingPeriod: "none",
tierGroup: "free",
features: FREE_FEATURES,
marketingFeatures: [
"Core dashboard panels",
"Global news feed",
"Earthquake & weather alerts",
"Basic map view",
],
selfServe: false,
highlighted: false,
currentForCheckout: false,
publicVisible: true,
},
pro_monthly: {
dodoProductId: "pdt_0Nbtt71uObulf7fGXhQup",
planKey: "pro_monthly",
displayName: "Pro Monthly",
priceCents: 1900,
billingPeriod: "monthly",
tierGroup: "pro",
features: PRO_FEATURES,
marketingFeatures: [
"Everything in Free",
"AI stock analysis & backtesting",
"Daily market briefs",
"Military & geopolitical tracking",
"Custom widget builder",
"MCP data connectors",
"Priority data refresh",
],
selfServe: true,
highlighted: true,
currentForCheckout: true,
publicVisible: true,
},
pro_annual: {
dodoProductId: "pdt_0NbttMIfjLWC10jHQWYgJ",
planKey: "pro_annual",
displayName: "Pro Annual",
priceCents: 19000,
billingPeriod: "annual",
tierGroup: "pro",
features: PRO_FEATURES,
marketingFeatures: [],
selfServe: true,
highlighted: true,
currentForCheckout: true,
publicVisible: true,
},
api_starter: {
dodoProductId: "pdt_0NbttVmG1SERrxhygbbUq",
planKey: "api_starter",
displayName: "API Starter Monthly",
priceCents: 4900,
billingPeriod: "monthly",
tierGroup: "api_starter",
features: API_STARTER_FEATURES,
marketingFeatures: [
"REST API access",
"Real-time data streams",
"1,000 requests/day",
"Webhook notifications",
"Custom data exports",
],
selfServe: true,
highlighted: false,
currentForCheckout: true,
publicVisible: true,
},
api_starter_annual: {
dodoProductId: "pdt_0Nbu2lawHYE3dv2THgSEV",
planKey: "api_starter_annual",
displayName: "API Starter Annual",
priceCents: 49000,
billingPeriod: "annual",
tierGroup: "api_starter",
features: API_STARTER_FEATURES,
marketingFeatures: [],
selfServe: true,
highlighted: false,
currentForCheckout: true,
publicVisible: true,
},
api_business: {
dodoProductId: "pdt_0Nbttg7NuOJrhbyBGCius",
planKey: "api_business",
displayName: "API Business",
priceCents: null,
billingPeriod: "monthly",
tierGroup: "api_business",
features: API_BUSINESS_FEATURES,
marketingFeatures: [],
selfServe: false,
highlighted: false,
currentForCheckout: false,
publicVisible: false,
},
enterprise: {
dodoProductId: "pdt_0Nbttnqrfh51cRqhMdVLx",
planKey: "enterprise",
displayName: "Enterprise",
priceCents: null,
billingPeriod: "none",
tierGroup: "enterprise",
features: ENTERPRISE_FEATURES,
marketingFeatures: [
"Everything in Pro + API",
"Unlimited API requests",
"Dedicated support",
"Custom integrations",
"SLA guarantee",
"On-premise option",
],
selfServe: false,
highlighted: false,
currentForCheckout: false,
publicVisible: true,
},
};
// ---------------------------------------------------------------------------
// Legacy product IDs from test mode (for webhook resolution of existing subs)
// ---------------------------------------------------------------------------
export const LEGACY_PRODUCT_ALIASES: Record<string, string> = {
"pdt_0NaysSFAQ0y30nJOJMBpg": "pro_monthly",
"pdt_0NaysWqJBx3laiCzDbQfr": "pro_annual",
"pdt_0NaysZwxCyk9Satf1jbqU": "api_starter",
"pdt_0NaysdZLwkMAPEVJQja5G": "api_business",
"pdt_0NaysgHSQTTqGjJdLtuWP": "enterprise",
};
// ---------------------------------------------------------------------------
// Derived helpers
// ---------------------------------------------------------------------------
export function getEntitlementFeatures(planKey: string): PlanFeatures {
const entry = PRODUCT_CATALOG[planKey];
if (!entry) {
throw new Error(
`[productCatalog] Unknown planKey "${planKey}". Add it to PRODUCT_CATALOG.`,
);
}
return entry.features;
}
export function resolveProductToPlan(dodoProductId: string): string | null {
const entry = Object.values(PRODUCT_CATALOG).find(
(e) => e.dodoProductId === dodoProductId,
);
if (entry) return entry.planKey;
return LEGACY_PRODUCT_ALIASES[dodoProductId] ?? null;
}
export function getCheckoutProducts(): CatalogEntry[] {
return Object.values(PRODUCT_CATALOG).filter((e) => e.currentForCheckout);
}
export function getPublicTiers(): CatalogEntry[] {
return Object.values(PRODUCT_CATALOG).filter((e) => e.publicVisible);
}
export function getSeedableProducts(): Array<{
dodoProductId: string;
planKey: string;
displayName: string;
isActive: boolean;
}> {
return Object.values(PRODUCT_CATALOG)
.filter((e): e is CatalogEntry & { dodoProductId: string } => !!e.dodoProductId)
.map((e) => ({
dodoProductId: e.dodoProductId,
planKey: e.planKey,
displayName: e.displayName,
isActive: true,
}));
}

View File

@@ -1,78 +1,21 @@
/**
* Plan-to-features configuration map.
* Plan-to-features resolution.
*
* This is config, not code. To add a new plan, add an entry to PLAN_FEATURES.
* To add a new feature dimension, extend PlanFeatures and update each entry.
* Features are defined in the canonical product catalog
* (convex/config/productCatalog.ts). This module re-exports the type
* and lookup function for backward compatibility.
*/
export type PlanFeatures = {
tier: number; // 0=free, 1=pro, 2=api, 3=enterprise — higher includes lower
maxDashboards: number; // -1 = unlimited
apiAccess: boolean;
apiRateLimit: number; // requests per minute, 0 = no access
prioritySupport: boolean;
exportFormats: string[];
};
import {
type PlanFeatures,
getEntitlementFeatures,
PRODUCT_CATALOG,
} from "../config/productCatalog";
/** Free tier defaults -- used as fallback for unknown plan keys. */
export const FREE_FEATURES: PlanFeatures = {
tier: 0,
maxDashboards: 3,
apiAccess: false,
apiRateLimit: 0,
prioritySupport: false,
exportFormats: ["csv"],
};
export type { PlanFeatures };
/**
* Maps plan keys to their entitled feature sets.
*
* Plan keys match the `planKey` field in the `productPlans` and
* `subscriptions` tables.
*/
/** Shared features for all Pro billing cycles (monthly/annual). */
const PRO_FEATURES: PlanFeatures = {
tier: 1,
maxDashboards: 10,
apiAccess: false,
apiRateLimit: 0,
prioritySupport: false,
exportFormats: ["csv", "pdf"],
};
export const PLAN_FEATURES: Record<string, PlanFeatures> = {
free: FREE_FEATURES,
pro_monthly: PRO_FEATURES,
pro_annual: PRO_FEATURES,
api_starter: {
tier: 2,
maxDashboards: 25,
apiAccess: true,
apiRateLimit: 60,
prioritySupport: false,
exportFormats: ["csv", "pdf", "json"],
},
api_business: {
tier: 2,
maxDashboards: 100,
apiAccess: true,
apiRateLimit: 300,
prioritySupport: true,
exportFormats: ["csv", "pdf", "json", "xlsx"],
},
enterprise: {
tier: 3,
maxDashboards: -1,
apiAccess: true,
apiRateLimit: 1000,
prioritySupport: true,
exportFormats: ["csv", "pdf", "json", "xlsx", "api-stream"],
},
};
/** Free tier defaults — used as fallback for unknown plan keys. */
export const FREE_FEATURES: PlanFeatures = PRODUCT_CATALOG.free!.features;
/**
* Returns the feature set for a given plan key.
@@ -80,12 +23,5 @@ export const PLAN_FEATURES: Record<string, PlanFeatures> = {
* instead of silently downgrading paid users to free tier.
*/
export function getFeaturesForPlan(planKey: string): PlanFeatures {
const features = PLAN_FEATURES[planKey];
if (!features) {
throw new Error(
`[entitlements] Unknown planKey "${planKey}". ` +
`Add it to PLAN_FEATURES in convex/lib/entitlements.ts.`,
);
}
return features;
return getEntitlementFeatures(planKey);
}

View File

@@ -1,49 +1,19 @@
// Seed mutation for Dodo product-to-plan mappings.
//
// Run this mutation after creating products in the Dodo dashboard.
// Replace REPLACE_WITH_DODO_ID with actual product IDs from the dashboard.
//
// Usage:
// npx convex run payments/seedProductPlans:seedProductPlans
// npx convex run payments/seedProductPlans:listProductPlans
/**
* Seed mutation for Dodo product-to-plan mappings.
*
* Reads from the canonical product catalog (convex/config/productCatalog.ts).
* Run after creating/updating products in the Dodo dashboard.
*
* Usage:
* npx convex run payments/seedProductPlans:seedProductPlans
* npx convex run payments/seedProductPlans:listProductPlans
*/
import { internalMutation, query } from "../_generated/server";
const PRODUCT_PLANS = [
{
dodoProductId: "pdt_0Nbtt71uObulf7fGXhQup",
planKey: "pro_monthly",
displayName: "Pro Monthly",
isActive: true,
},
{
dodoProductId: "pdt_0NbttMIfjLWC10jHQWYgJ",
planKey: "pro_annual",
displayName: "Pro Annual",
isActive: true,
},
{
dodoProductId: "pdt_0NbttVmG1SERrxhygbbUq",
planKey: "api_starter",
displayName: "API Starter",
isActive: true,
},
{
dodoProductId: "pdt_0Nbttg7NuOJrhbyBGCius",
planKey: "api_business",
displayName: "API Business",
isActive: true,
},
{
dodoProductId: "pdt_0Nbttnqrfh51cRqhMdVLx",
planKey: "enterprise",
displayName: "Enterprise",
isActive: true,
},
] as const;
import { getSeedableProducts } from "../config/productCatalog";
/**
* Upsert 5 product-to-plan mappings into the productPlans table.
* Upsert product-to-plan mappings from the canonical catalog.
* Idempotent: running twice will update existing records rather than
* creating duplicates, thanks to the by_planKey index lookup.
*/
@@ -53,7 +23,7 @@ export const seedProductPlans = internalMutation({
let created = 0;
let updated = 0;
for (const plan of PRODUCT_PLANS) {
for (const plan of getSeedableProducts()) {
const existing = await ctx.db
.query("productPlans")
.withIndex("by_planKey", (q) => q.eq("planKey", plan.planKey))
@@ -83,8 +53,6 @@ export const seedProductPlans = internalMutation({
/**
* List all active product plans, sorted by planKey.
* Useful for verifying the seed worked and for later phases
* that need to map Dodo products to internal plan keys.
*/
export const listProductPlans = query({
args: {},

View File

@@ -125,8 +125,9 @@ export async function upsertEntitlements(
/**
* Resolves a Dodo product ID to a plan key via the productPlans table.
* Throws if the product ID is not mapped — the webhook will be retried
* and the operator should add the missing product mapping.
* Falls back to LEGACY_PRODUCT_ALIASES for old test-mode product IDs
* that may still appear on existing subscriber webhooks.
* Throws if the product ID is not mapped anywhere.
*/
async function resolvePlanKey(
ctx: MutationCtx,
@@ -136,13 +137,23 @@ async function resolvePlanKey(
.query("productPlans")
.withIndex("by_dodoProductId", (q) => q.eq("dodoProductId", dodoProductId))
.unique();
if (!mapping) {
throw new Error(
`[subscriptionHelpers] No productPlans mapping for dodoProductId="${dodoProductId}". ` +
`Add this product to the seed data and run seedProductPlans.`,
if (mapping) return mapping.planKey;
// Fallback: check legacy aliases for old/rotated product IDs
const { LEGACY_PRODUCT_ALIASES } = await import("../config/productCatalog");
const aliasedPlan = LEGACY_PRODUCT_ALIASES[dodoProductId];
if (aliasedPlan) {
console.warn(
`[subscriptionHelpers] Resolved "${dodoProductId}" via legacy alias → "${aliasedPlan}". ` +
`Consider updating the subscription to the current product ID.`,
);
return aliasedPlan;
}
return mapping.planKey;
throw new Error(
`[subscriptionHelpers] No productPlans mapping for dodoProductId="${dodoProductId}". ` +
`Add this product to the catalog and run seedProductPlans.`,
);
}
/**

View File

@@ -2,89 +2,26 @@ import { useState } from 'react';
import { motion } from 'motion/react';
import { Check, ArrowRight, Zap } from 'lucide-react';
import tiersData from '../generated/tiers.json';
interface Tier {
name: string;
description: string;
features: string[];
highlighted?: boolean;
// Pricing variants
price?: number | null;
period?: string;
monthlyPrice?: number;
annualPrice?: number | null;
// CTA variants
cta?: string;
href?: string;
monthlyProductId?: string;
annualProductId?: string;
}
const TIERS: Tier[] = [
{
name: "Free",
price: 0,
period: "forever",
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,
},
{
name: "Pro",
monthlyPrice: 19,
annualPrice: 190,
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",
],
monthlyProductId: "pdt_0Nbtt71uObulf7fGXhQup",
annualProductId: "pdt_0NbttMIfjLWC10jHQWYgJ",
highlighted: true,
},
{
name: "API",
monthlyPrice: 49,
annualPrice: null,
description: "Programmatic access to intelligence data",
features: [
"REST API access",
"Real-time data streams",
"10,000 requests/day",
"Webhook notifications",
"Custom data exports",
],
monthlyProductId: "pdt_0NbttVmG1SERrxhygbbUq",
highlighted: false,
},
{
name: "Enterprise",
price: null,
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,
},
];
// 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 APP_CHECKOUT_BASE_URL = 'https://worldmonitor.app/';

View File

@@ -0,0 +1,67 @@
[
{
"name": "Free",
"price": 0,
"period": "forever",
"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
},
{
"name": "Pro",
"monthlyPrice": 19,
"annualPrice": 190,
"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"
],
"monthlyProductId": "pdt_0Nbtt71uObulf7fGXhQup",
"annualProductId": "pdt_0NbttMIfjLWC10jHQWYgJ",
"highlighted": true
},
{
"name": "API",
"monthlyPrice": 49,
"annualPrice": 490,
"description": "Programmatic access to intelligence data",
"features": [
"REST API access",
"Real-time data streams",
"1,000 requests/day",
"Webhook notifications",
"Custom data exports"
],
"monthlyProductId": "pdt_0NbttVmG1SERrxhygbbUq",
"annualProductId": "pdt_0Nbu2lawHYE3dv2THgSEV",
"highlighted": false
},
{
"name": "Enterprise",
"price": null,
"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
}
]

View File

@@ -20,6 +20,7 @@
"./*"
]
},
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true
}

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-BTy1K-t4.js"></script>
<script type="module" crossorigin src="/pro/assets/index-BQffyi10.js"></script>
<link rel="stylesheet" crossorigin href="/pro/assets/index-CE0ARBnG.css">
</head>
<body>

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* Generate product configuration files from the canonical catalog.
*
* Reads: convex/config/productCatalog.ts
* Writes:
* - src/config/products.generated.ts (product IDs for dashboard)
* - pro-test/src/generated/tiers.json (tier view model for /pro page)
*
* Usage: npx tsx scripts/generate-product-config.mjs
*/
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// Dynamic import so tsx handles the TS transpilation
const { PRODUCT_CATALOG } = await import('../convex/config/productCatalog.ts');
// ---------------------------------------------------------------------------
// 1. Generate src/config/products.generated.ts
// ---------------------------------------------------------------------------
// Build the DODO_PRODUCTS export preserving existing key naming convention:
// PRO_MONTHLY, PRO_ANNUAL, API_STARTER_MONTHLY, API_STARTER_ANNUAL, API_BUSINESS, ENTERPRISE
const KEY_MAP = {
pro_monthly: 'PRO_MONTHLY',
pro_annual: 'PRO_ANNUAL',
api_starter: 'API_STARTER_MONTHLY',
api_starter_annual: 'API_STARTER_ANNUAL',
api_business: 'API_BUSINESS',
enterprise: 'ENTERPRISE',
};
const productEntries = Object.entries(PRODUCT_CATALOG)
.filter(([, e]) => e.dodoProductId)
.map(([key, e]) => {
const exportKey = KEY_MAP[key] || key.toUpperCase();
return ` ${exportKey}: '${e.dodoProductId}',`;
})
.join('\n');
const productsTs = `// AUTO-GENERATED from convex/config/productCatalog.ts
// Do not edit manually. Run: npx tsx scripts/generate-product-config.mjs
export const DODO_PRODUCTS = {
${productEntries}
} as const;
/** Default product for upgrade CTAs (Pro Monthly). */
export const DEFAULT_UPGRADE_PRODUCT = DODO_PRODUCTS.PRO_MONTHLY;
`;
const productsPath = join(ROOT, 'src/config/products.generated.ts');
writeFileSync(productsPath, productsTs);
console.log(`${productsPath}`);
// ---------------------------------------------------------------------------
// 2. Generate pro-test/src/generated/tiers.json
// ---------------------------------------------------------------------------
// Group catalog entries by tierGroup, merge monthly/annual into Tier view model
const tierGroups = new Map();
for (const entry of Object.values(PRODUCT_CATALOG)) {
if (!entry.publicVisible) continue;
if (!tierGroups.has(entry.tierGroup)) {
tierGroups.set(entry.tierGroup, []);
}
tierGroups.get(entry.tierGroup).push(entry);
}
const tiers = [];
for (const [, entries] of tierGroups) {
const monthly = entries.find((e) => e.billingPeriod === 'monthly');
const annual = entries.find((e) => e.billingPeriod === 'annual');
const primary = monthly || entries[0];
// Use marketing features from the monthly variant (or first entry)
const marketingFeatures =
primary.marketingFeatures.length > 0
? primary.marketingFeatures
: (annual?.marketingFeatures?.length > 0 ? annual.marketingFeatures : []);
const tier = { name: getTierDisplayName(primary.tierGroup) };
if (primary.priceCents === 0) {
// Free tier
tier.price = 0;
tier.period = 'forever';
} else if (primary.priceCents === null) {
// Custom/contact tier
tier.price = null;
} else {
// Paid tier with monthly price
tier.monthlyPrice = primary.priceCents / 100;
}
if (annual && annual.priceCents != null) {
tier.annualPrice = annual.priceCents / 100;
}
tier.description = getDescription(primary.tierGroup);
tier.features = marketingFeatures;
if (primary.selfServe && primary.dodoProductId) {
tier.monthlyProductId = primary.dodoProductId;
if (annual?.dodoProductId) {
tier.annualProductId = annual.dodoProductId;
}
} else if (!primary.selfServe && primary.priceCents === 0) {
tier.cta = 'Get Started';
tier.href = 'https://worldmonitor.app';
} else if (!primary.selfServe && primary.priceCents === null) {
tier.cta = 'Contact Sales';
tier.href = 'mailto:enterprise@worldmonitor.app';
}
tier.highlighted = primary.highlighted;
tiers.push(tier);
}
const tiersPath = join(ROOT, 'pro-test/src/generated/tiers.json');
writeFileSync(tiersPath, JSON.stringify(tiers, null, 2) + '\n');
console.log(`${tiersPath}`);
console.log('\nDone. Remember to rebuild /pro: cd pro-test && npm run build');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getTierDisplayName(tierGroup) {
const names = {
free: 'Free',
pro: 'Pro',
api_starter: 'API',
api_business: 'API Business',
enterprise: 'Enterprise',
};
return names[tierGroup] || tierGroup;
}
function getDescription(tierGroup) {
const descriptions = {
free: 'Get started with the essentials',
pro: 'Full intelligence dashboard',
api_starter: 'Programmatic access to intelligence data',
api_business: 'High-volume API for teams',
enterprise: 'Custom solutions for organizations',
};
return descriptions[tierGroup] || '';
}

View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED from convex/config/productCatalog.ts
// Do not edit manually. Run: npx tsx scripts/generate-product-config.mjs
export const DODO_PRODUCTS = {
PRO_MONTHLY: 'pdt_0Nbtt71uObulf7fGXhQup',
PRO_ANNUAL: 'pdt_0NbttMIfjLWC10jHQWYgJ',
API_STARTER_MONTHLY: 'pdt_0NbttVmG1SERrxhygbbUq',
API_STARTER_ANNUAL: 'pdt_0Nbu2lawHYE3dv2THgSEV',
API_BUSINESS: 'pdt_0Nbttg7NuOJrhbyBGCius',
ENTERPRISE: 'pdt_0Nbttnqrfh51cRqhMdVLx',
} as const;
/** Default product for upgrade CTAs (Pro Monthly). */
export const DEFAULT_UPGRADE_PRODUCT = DODO_PRODUCTS.PRO_MONTHLY;

View File

@@ -1,17 +1,11 @@
/**
* Dodo Payments product configuration.
* Dodo Payments product IDs for frontend checkout CTAs.
*
* Single source of truth for product IDs used in frontend checkout CTAs.
* These must match the IDs in convex/payments/seedProductPlans.ts.
* AUTO-GENERATED source: src/config/products.generated.ts
* Canonical source: convex/config/productCatalog.ts
*
* This file re-exports from the generated file for backward compatibility.
* Do not add product IDs here manually.
*/
export const DODO_PRODUCTS = {
PRO_MONTHLY: 'pdt_0Nbtt71uObulf7fGXhQup',
PRO_ANNUAL: 'pdt_0NbttMIfjLWC10jHQWYgJ',
API_STARTER: 'pdt_0NbttVmG1SERrxhygbbUq',
API_BUSINESS: 'pdt_0Nbttg7NuOJrhbyBGCius',
ENTERPRISE: 'pdt_0Nbttnqrfh51cRqhMdVLx',
} as const;
/** Default product for upgrade CTAs (Pro Monthly). */
export const DEFAULT_UPGRADE_PRODUCT = DODO_PRODUCTS.PRO_MONTHLY;
export { DODO_PRODUCTS, DEFAULT_UPGRADE_PRODUCT } from './products.generated';

View File

@@ -0,0 +1,157 @@
/**
* Product catalog freshness tests.
*
* Verifies that generated files (products.generated.ts, tiers.json)
* match the canonical catalog in convex/config/productCatalog.ts.
* Bidirectional: checks generated→catalog AND catalog→generated.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
describe('Product catalog freshness', () => {
// Read generated files
const generatedProductsSrc = readFileSync(join(ROOT, 'src/config/products.generated.ts'), 'utf8');
const tiersJson = JSON.parse(readFileSync(join(ROOT, 'pro-test/src/generated/tiers.json'), 'utf8'));
// Extract product IDs from generated TS (regex since we can't import TS in node:test)
const generatedProductIds = [...generatedProductsSrc.matchAll(/'(pdt_[^']+)'/g)].map(m => m[1]);
it('generated products.ts contains valid product IDs', () => {
assert.ok(generatedProductIds.length >= 4, `Expected at least 4 product IDs, got ${generatedProductIds.length}`);
for (const id of generatedProductIds) {
assert.match(id, /^pdt_/, `Product ID should start with pdt_: ${id}`);
}
});
it('generated tiers.json has expected tier structure', () => {
assert.ok(Array.isArray(tiersJson), 'tiers.json should be an array');
assert.ok(tiersJson.length >= 3, `Expected at least 3 tiers, got ${tiersJson.length}`);
const names = tiersJson.map(t => t.name);
assert.ok(names.includes('Free'), 'Missing Free tier');
assert.ok(names.includes('Pro'), 'Missing Pro tier');
assert.ok(names.includes('API'), 'Missing API tier');
});
it('Pro tier has monthly and annual prices', () => {
const pro = tiersJson.find(t => t.name === 'Pro');
assert.ok(pro, 'Pro tier not found');
assert.ok(typeof pro.monthlyPrice === 'number', 'Pro should have monthlyPrice');
assert.ok(typeof pro.annualPrice === 'number', 'Pro should have annualPrice');
assert.ok(pro.monthlyProductId, 'Pro should have monthlyProductId');
assert.ok(pro.annualProductId, 'Pro should have annualProductId');
});
it('API tier has monthly and annual prices', () => {
const api = tiersJson.find(t => t.name === 'API');
assert.ok(api, 'API tier not found');
assert.ok(typeof api.monthlyPrice === 'number', 'API should have monthlyPrice');
assert.ok(typeof api.annualPrice === 'number', 'API should have annualPrice');
});
it('Enterprise tier is custom with contact CTA', () => {
const ent = tiersJson.find(t => t.name === 'Enterprise');
assert.ok(ent, 'Enterprise tier not found');
assert.equal(ent.price, null, 'Enterprise price should be null');
assert.equal(ent.cta, 'Contact Sales');
});
it('every currentForCheckout catalog entry appears in generated products', () => {
// Reverse check: catalog → generated. Catches generator silently dropping entries.
// Import catalog via the generator's own output (re-run to get fresh data)
execSync('npx tsx scripts/generate-product-config.mjs', { cwd: ROOT, stdio: 'pipe' });
const freshProducts = readFileSync(join(ROOT, 'src/config/products.generated.ts'), 'utf8');
const allGeneratedIds = [...freshProducts.matchAll(/'(pdt_[^']+)'/g)].map(m => m[1]);
// Read catalog entries that should be in generated (currentForCheckout with a dodoProductId)
// Parse from the catalog source file since we can't import TS
const catalogSrc = readFileSync(join(ROOT, 'convex/config/productCatalog.ts'), 'utf8');
const checkoutBlocks = catalogSrc.split(/\n\s*\w+:\s*\{/).slice(1);
for (const block of checkoutBlocks) {
const hasCheckout = block.includes('currentForCheckout: true');
const idMatch = block.match(/dodoProductId:\s*["']([^"']+)["']/);
if (hasCheckout && idMatch) {
assert.ok(
allGeneratedIds.includes(idMatch[1]),
`Catalog entry with dodoProductId ${idMatch[1]} has currentForCheckout=true but is missing from products.generated.ts`,
);
}
}
});
it('every publicVisible tier group appears in generated tiers.json', () => {
const catalogSrc = readFileSync(join(ROOT, 'convex/config/productCatalog.ts'), 'utf8');
const tierNames = tiersJson.map(t => t.name);
// Extract publicVisible tier groups from catalog
const blocks = catalogSrc.split(/\n\s*\w+:\s*\{/).slice(1);
const visibleGroups = new Set();
for (const block of blocks) {
if (block.includes('publicVisible: true')) {
const groupMatch = block.match(/tierGroup:\s*["']([^"']+)["']/);
if (groupMatch) visibleGroups.add(groupMatch[1]);
}
}
// Each visible group should have a corresponding tier in the JSON
// Map group names to expected display names
const groupToName = { free: 'Free', pro: 'Pro', api_starter: 'API', enterprise: 'Enterprise' };
for (const group of visibleGroups) {
const expectedName = groupToName[group] || group;
assert.ok(
tierNames.includes(expectedName),
`Catalog tier group "${group}" is publicVisible but missing from tiers.json (expected name: "${expectedName}")`,
);
}
});
it('generated files are fresh (re-running generator produces same output)', () => {
// Capture current generated content
const currentProducts = readFileSync(join(ROOT, 'src/config/products.generated.ts'), 'utf8');
const currentTiers = readFileSync(join(ROOT, 'pro-test/src/generated/tiers.json'), 'utf8');
// Re-run generator
execSync('npx tsx scripts/generate-product-config.mjs', { cwd: ROOT, stdio: 'pipe' });
// Compare
const freshProducts = readFileSync(join(ROOT, 'src/config/products.generated.ts'), 'utf8');
const freshTiers = readFileSync(join(ROOT, 'pro-test/src/generated/tiers.json'), 'utf8');
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');
});
});
describe('Product ID guard', () => {
it('no raw pdt_ strings outside allowed paths', () => {
// Allowed paths: catalog, generated files, tests, built assets
const result = execSync(
`grep -rn 'pdt_' --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.js' . ` +
`| grep -v node_modules ` +
`| grep -v 'convex/config/productCatalog' ` +
`| grep -v 'src/config/products.generated' ` +
`| grep -v 'pro-test/src/generated/' ` +
`| grep -v 'public/pro/' ` +
`| grep -v 'tests/' ` +
`| grep -v 'convex/__tests__/' ` +
`| grep -v 'scripts/generate-product-config' ` +
`| grep -v '.test.' ` +
`|| true`,
{ cwd: ROOT, encoding: 'utf8' },
).trim();
if (result) {
assert.fail(
`Found pdt_ strings outside allowed paths. These should import from the catalog:\n${result}`,
);
}
});
});