mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
282
convex/config/productCatalog.ts
Normal file
282
convex/config/productCatalog.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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/';
|
||||
|
||||
|
||||
67
pro-test/src/generated/tiers.json
Normal file
67
pro-test/src/generated/tiers.json
Normal 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
|
||||
}
|
||||
]
|
||||
@@ -20,6 +20,7 @@
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
156
scripts/generate-product-config.mjs
Normal file
156
scripts/generate-product-config.mjs
Normal 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] || '';
|
||||
}
|
||||
14
src/config/products.generated.ts
Normal file
14
src/config/products.generated.ts
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
157
tests/product-catalog-freshness.test.mjs
Normal file
157
tests/product-catalog-freshness.test.mjs
Normal 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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user