From 527be31c24640f12d35f9fef5f4542e4ff493e44 Mon Sep 17 00:00:00 2001 From: Mohammed Rayan A Date: Wed, 29 Jul 2026 18:05:50 +0530 Subject: [PATCH] feat(monetization): add DodoPayments webhook, subscription helpers, pricing checkout links, and Pro tier feature gates --- app/api/_utils/subscription.ts | 125 ++++++++++++++++++++ app/api/ai/ask/route.ts | 17 +++ app/api/resources/pdf/route.ts | 13 +++ app/api/resources/route.ts | 14 +++ app/api/webhooks/dodopayments/route.ts | 152 +++++++++++++++++++++++++ app/components/landing/Pricing.tsx | 111 +++++++++++------- docs/api-spec.md | 13 +++ docs/data-model.md | 30 +++++ 8 files changed, 435 insertions(+), 40 deletions(-) create mode 100644 app/api/_utils/subscription.ts create mode 100644 app/api/webhooks/dodopayments/route.ts diff --git a/app/api/_utils/subscription.ts b/app/api/_utils/subscription.ts new file mode 100644 index 0000000..b078217 --- /dev/null +++ b/app/api/_utils/subscription.ts @@ -0,0 +1,125 @@ +import { getServerFirestore } from './firebaseAdmin'; + +export interface UserSubscriptionInfo { + isPro: boolean; + plan: 'free' | 'pro' | 'api'; + status: 'active' | 'inactive' | 'cancelled' | 'expired'; + subscriptionId?: string; +} + +export const getUserSubscription = async (uid: string): Promise => { + try { + const db = getServerFirestore(); + const userDoc = await db.collection('users').doc(uid).get(); + + if (!userDoc.exists) { + return { isPro: false, plan: 'free', status: 'inactive' }; + } + + const userData = userDoc.data(); + const sub = userData?.subscription; + + if (sub && (sub.status === 'active' || sub.status === 'succeeded' || sub.status === 'trialing')) { + return { + isPro: true, + plan: sub.plan || 'pro', + status: 'active', + subscriptionId: sub.subscription_id, + }; + } + + return { + isPro: false, + plan: 'free', + status: sub?.status || 'inactive', + }; + } catch (error) { + console.error('Error fetching user subscription:', error); + return { isPro: false, plan: 'free', status: 'inactive' }; + } +}; + +export const checkResourceLimit = async ( + uid: string +): Promise<{ isLimited: boolean; count: number; max: number }> => { + const subInfo = await getUserSubscription(uid); + + // Pro and API tier users have unlimited resources + if (subInfo.isPro) { + return { isLimited: false, count: 0, max: Infinity }; + } + + const FREE_RESOURCE_LIMIT = 50; + const db = getServerFirestore(); + const resourcesSnapshot = await db + .collection('resources') + .where('user_id', '==', uid) + .select() + .get(); + + const count = resourcesSnapshot.size; + + return { + isLimited: count >= FREE_RESOURCE_LIMIT, + count, + max: FREE_RESOURCE_LIMIT, + }; +}; + +const getYearMonthKey = (): string => { + const date = new Date(); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +}; + +export const checkAiQueryLimit = async ( + uid: string +): Promise<{ isLimited: boolean; count: number; max: number }> => { + const subInfo = await getUserSubscription(uid); + + // Pro users get 300 queries/month, API plan gets 500 + const max = subInfo.isPro ? (subInfo.plan === 'api' ? 500 : 300) : 15; + const db = getServerFirestore(); + const ymKey = getYearMonthKey(); + + const usageDoc = await db + .collection('users') + .doc(uid) + .collection('ai_usage') + .doc(ymKey) + .get(); + + const count = usageDoc.exists ? usageDoc.data()?.count || 0 : 0; + + return { + isLimited: count >= max, + count, + max, + }; +}; + +export const incrementAiQueryCount = async (uid: string): Promise => { + try { + const db = getServerFirestore(); + const ymKey = getYearMonthKey(); + const usageRef = db.collection('users').doc(uid).collection('ai_usage').doc(ymKey); + + const doc = await usageRef.get(); + if (doc.exists) { + await usageRef.update({ + count: (doc.data()?.count || 0) + 1, + updated_at: new Date(), + }); + } else { + await usageRef.set({ + count: 1, + year_month: ymKey, + created_at: new Date(), + updated_at: new Date(), + }); + } + } catch (error) { + console.error('Error incrementing AI query count:', error); + } +}; diff --git a/app/api/ai/ask/route.ts b/app/api/ai/ask/route.ts index df46819..6a3fef7 100644 --- a/app/api/ai/ask/route.ts +++ b/app/api/ai/ask/route.ts @@ -3,6 +3,7 @@ import { buildAnswerContext, normalizeSearchMode, searchResourceChunks } from '. import { isAuthError, requireAuth, unauthorizedResponse } from '../../_utils/auth'; import { generateAnswer } from '../../_utils/gemini'; import { checkAiRateLimit } from '../../_utils/rateLimit'; +import { checkAiQueryLimit, incrementAiQueryCount } from '../../_utils/subscription'; export async function POST(request: NextRequest) { try { @@ -11,6 +12,19 @@ export async function POST(request: NextRequest) { // Rate limit: 10 AI queries per minute per authenticated user const rateLimitResponse = await checkAiRateLimit(request, authUser.uid); if (rateLimitResponse) return rateLimitResponse; + + // Check monthly AI query tier limit + const aiQueryLimitCheck = await checkAiQueryLimit(authUser.uid); + if (aiQueryLimitCheck.isLimited) { + return NextResponse.json( + { + error: `Monthly AI query limit (${aiQueryLimitCheck.max}) reached. Upgrade to Pro for 300 queries/month.`, + code: 'UPGRADE_REQUIRED', + }, + { status: 403 } + ); + } + const { question, mode, limit } = await request.json(); if (!question || typeof question !== 'string') { @@ -34,6 +48,9 @@ export async function POST(request: NextRequest) { const answer = await generateAnswer(question, buildAnswerContext(results)); + // Increment AI query usage count + await incrementAiQueryCount(authUser.uid); + return NextResponse.json({ success: true, answer, diff --git a/app/api/resources/pdf/route.ts b/app/api/resources/pdf/route.ts index f830f4b..da3190e 100644 --- a/app/api/resources/pdf/route.ts +++ b/app/api/resources/pdf/route.ts @@ -4,6 +4,7 @@ import { getServerFirestore } from '../../_utils/firebaseAdmin'; import { extractTextFromPdf } from '../../_utils/pdfExtractor'; import { checkAuthenticatedRateLimit } from '../../_utils/rateLimit'; import { indexResource } from '../../_utils/resourceIndexer'; +import { checkResourceLimit } from '../../_utils/subscription'; export async function POST(request: NextRequest) { try { @@ -13,6 +14,18 @@ export async function POST(request: NextRequest) { const authRateLimitResponse = await checkAuthenticatedRateLimit(request, authUser.uid); if (authRateLimitResponse) return authRateLimitResponse; + // Check Free tier resource limit + const resourceLimitCheck = await checkResourceLimit(authUser.uid); + if (resourceLimitCheck.isLimited) { + return NextResponse.json( + { + error: `Free tier resource limit (${resourceLimitCheck.max}) reached. Upgrade to Pro for unlimited resource saves.`, + code: 'UPGRADE_REQUIRED', + }, + { status: 403 } + ); + } + const formData = await request.formData(); const file = formData.get('file') as File | null; const customTitle = (formData.get('title') as string) || ''; diff --git a/app/api/resources/route.ts b/app/api/resources/route.ts index f4f5241..bcc448d 100644 --- a/app/api/resources/route.ts +++ b/app/api/resources/route.ts @@ -4,6 +4,7 @@ import { getServerFirestore } from '../_utils/firebaseAdmin'; import { getPreviewFromUrl } from '../_utils/linkPreview'; import { checkAuthenticatedRateLimit, checkPublicRateLimit } from '../_utils/rateLimit'; import { indexResource } from '../_utils/resourceIndexer'; +import { checkResourceLimit } from '../_utils/subscription'; // GET /api/resources - Get the authenticated user's resources export async function GET(request: NextRequest) { @@ -121,6 +122,19 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { const authUser = await requireAuth(request); + + // Check Free tier resource limit + const resourceLimitCheck = await checkResourceLimit(authUser.uid); + if (resourceLimitCheck.isLimited) { + return NextResponse.json( + { + error: `Free tier resource limit (${resourceLimitCheck.max}) reached. Upgrade to Pro for unlimited resource saves.`, + code: 'UPGRADE_REQUIRED', + }, + { status: 403 } + ); + } + const body = await request.json(); const { title, link, note, tag, is_public, collection_ids, new_collection, captured_text } = body; diff --git a/app/api/webhooks/dodopayments/route.ts b/app/api/webhooks/dodopayments/route.ts new file mode 100644 index 0000000..e535807 --- /dev/null +++ b/app/api/webhooks/dodopayments/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { getServerFirestore } from '../../_utils/firebaseAdmin'; + +const verifyWebhookSignature = ( + rawBody: string, + signatureHeader: string | null, + secret: string +): boolean => { + if (!signatureHeader) return false; + + try { + const hmac = crypto.createHmac('sha256', secret); + const calculatedSignature = hmac.update(rawBody).digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(calculatedSignature), + Buffer.from(signatureHeader) + ); + } catch (err) { + console.error('Error verifying webhook signature:', err); + return false; + } +}; + +export async function POST(request: NextRequest) { + try { + const rawBody = await request.text(); + const webhookSecret = process.env.DODOPAYMENTS_WEBHOOK_SECRET; + + if (webhookSecret) { + const signature = + request.headers.get('webhook-signature') || + request.headers.get('x-dodopayments-signature'); + + if (!verifyWebhookSignature(rawBody, signature, webhookSecret)) { + console.warn('DodoPayments webhook signature verification failed'); + return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 }); + } + } + + let payload: any; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); + } + + const eventType: string = payload.type || payload.event || ''; + const eventData = payload.data || payload; + + const metadata = eventData.metadata || {}; + const userId = metadata.user_id || metadata.uid || metadata.userId; + const customerEmail = + eventData.customer?.email || eventData.customer_email || payload.customer_email; + const subscriptionId = + eventData.subscription_id || eventData.id || payload.subscription_id || `sub_${Date.now()}`; + const productId = eventData.product_id || metadata.product_id; + + const db = getServerFirestore(); + + // Find target user ID + let targetUid = userId; + if (!targetUid && customerEmail) { + const userSnapshot = await db + .collection('users') + .where('email', '==', customerEmail) + .limit(1) + .get(); + + if (!userSnapshot.empty) { + targetUid = userSnapshot.docs[0].id; + } + } + + if (!targetUid) { + console.warn('DodoPayments webhook received but could not resolve user_id:', { + eventType, + customerEmail, + metadata, + }); + return NextResponse.json( + { success: true, message: 'Webhook logged but user_id not matched' }, + { status: 200 } + ); + } + + const now = new Date(); + const isSuccessEvent = [ + 'payment.succeeded', + 'subscription.active', + 'subscription.created', + 'subscription.renewed', + 'checkout.session.completed', + ].includes(eventType); + + const isCancellationEvent = [ + 'subscription.cancelled', + 'subscription.expired', + 'subscription.failed', + 'payment.failed', + ].includes(eventType); + + let newStatus: 'active' | 'cancelled' | 'inactive' = 'inactive'; + if (isSuccessEvent) { + newStatus = 'active'; + } else if (isCancellationEvent) { + newStatus = 'cancelled'; + } + + const plan = productId?.includes('api') ? 'api' : 'pro'; + + // Update user profile with subscription status + const userRef = db.collection('users').doc(targetUid); + await userRef.set( + { + subscription: { + status: newStatus, + plan: newStatus === 'active' ? plan : 'free', + subscription_id: subscriptionId, + customer_email: customerEmail || null, + product_id: productId || null, + updated_at: now, + }, + updated_at: now, + }, + { merge: true } + ); + + // Save event log to subscriptions collection + const subLogRef = db.collection('subscriptions').doc(`${subscriptionId}_${Date.now()}`); + await subLogRef.set({ + subscription_id: subscriptionId, + user_id: targetUid, + customer_email: customerEmail || null, + event_type: eventType, + status: newStatus, + plan, + payload, + created_at: now, + }); + + console.log(`DodoPayments webhook processed successfully for user ${targetUid}: ${eventType} -> ${newStatus}`); + + return NextResponse.json({ + success: true, + message: `Webhook processed for user ${targetUid}`, + }); + } catch (error) { + console.error('Error processing DodoPayments webhook:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/components/landing/Pricing.tsx b/app/components/landing/Pricing.tsx index ff21f1f..b7a4c0c 100644 --- a/app/components/landing/Pricing.tsx +++ b/app/components/landing/Pricing.tsx @@ -1,4 +1,7 @@ -import { ArrowRight, CheckCircle2, Flame, ShieldCheck, Zap } from 'lucide-react' +'use client'; + +import { ArrowRight, CheckCircle2, Flame, ShieldCheck, Zap } from 'lucide-react'; +import { useAuth } from '../../contexts/AuthContext'; const tiers = [ { @@ -50,9 +53,28 @@ const tiers = [ '5 API keys', ], }, -] +]; const Pricing = () => { + const { user } = useAuth(); + + const getDodoCheckoutUrl = (plan: 'pro' | 'api' = 'pro') => { + const defaultUrl = process.env.NEXT_PUBLIC_DODOPAYMENTS_CHECKOUT_URL || 'https://checkout.dodopayments.com/buy/pro'; + try { + const url = new URL(defaultUrl); + url.searchParams.set('metadata.plan', plan); + if (user) { + url.searchParams.set('metadata.user_id', user.uid); + if (user.email) { + url.searchParams.set('customer_email', user.email); + } + } + return url.toString(); + } catch { + return defaultUrl; + } + }; + return (
@@ -70,11 +92,12 @@ const Pricing = () => {

$4.50/month instead of $9. Locked in as long as your subscription is active.

- {/* No scarcity counter — add a real claimed-spot number here when you have one */}
@@ -82,8 +105,8 @@ const Pricing = () => {
- - Stripe-secured · 30-day money back + + Secured by DodoPayments · 30-day money back
@@ -102,44 +125,52 @@ const Pricing = () => { {/* Tiers */}
- {tiers.map((tier) => ( -
- {tier.badge && ( -
- {tier.badge} -
- )} -
-
{tier.name}
-
- {tier.price} - {tier.period && {tier.period}} + {tiers.map((tier) => { + const hrefUrl = tier.name === 'Starter' + ? tier.cta.href + : getDodoCheckoutUrl(tier.name === 'API / Dev' ? 'api' : 'pro'); + + return ( +
+ {tier.badge && ( +
+ {tier.badge} +
+ )} +
+
{tier.name}
+
+ {tier.price} + {tier.period && {tier.period}} +
+ {tier.annualNote &&
{tier.annualNote}
} +

{tier.description}

- {tier.annualNote &&
{tier.annualNote}
} -

{tier.description}

-
- - {tier.cta.label} - + + {tier.cta.label} + -
    - {tier.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
- ))} +
    + {tier.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ ); + })}
- ) -} + ); +}; -export default Pricing +export default Pricing; diff --git a/docs/api-spec.md b/docs/api-spec.md index acf2762..366b899 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -21,6 +21,7 @@ Server routes derive `uid` from the verified Firebase token and do not trust cli - **POST:** Create a link or text note resource. - Body: `{ title, link?, note?, tag?, is_public?, collection_ids?, new_collection?, captured_text? }`. - Duplicate detection: Returns `409 Conflict` if the link URL was already saved by the user. + - Tier limits: Returns `403 Forbidden` (`UPGRADE_REQUIRED`) if non-Pro user reaches 50 saved resources limit. - **PUT:** Update an owned resource. Body: `{ id, title, link, note, tag, is_public, collection_ids }`. - **DELETE:** Delete an owned resource. Query: `?id=`. @@ -36,6 +37,7 @@ Server routes derive `uid` from the verified Firebase token and do not trust cli - `is_public` (optional): `'true'` | `'false'`. - `collection_ids` (optional): JSON array string of collection IDs. - Processing: Parses PDF in memory via `pdf-parse`, extracts text (up to 50,000 characters), creates a resource with `tag: 'PDF'`, and queues background RAG indexing. + - Tier limits: Returns `403 Forbidden` (`UPGRADE_REQUIRED`) if non-Pro user reaches 50 saved resources limit. - Response: `{ success, resource }`. --- @@ -89,6 +91,7 @@ Server routes derive `uid` from the verified Firebase token and do not trust cli ## /api/ai/ask - **POST:** RAG answer generation with citations. - **Body:** `{ "question": "What should I read about Firebase auth?", "mode": "mine" | "shared" | "all", "limit": 8 }`. +- **Tier limits:** Returns `403 Forbidden` (`UPGRADE_REQUIRED`) if non-Pro user exceeds 15 AI Ask queries per month. - **Response:** `{ success, answer, sources }`. --- @@ -100,9 +103,19 @@ Server routes derive `uid` from the verified Firebase token and do not trust cli --- +## /api/webhooks/dodopayments +- **POST:** DodoPayments webhook handler. + - Signature Header: `webhook-signature` or `x-dodopayments-signature` (HMAC SHA-256 using `DODOPAYMENTS_WEBHOOK_SECRET`). + - Supported Events: `payment.succeeded`, `subscription.active`, `subscription.created`, `subscription.renewed`, `subscription.cancelled`, `subscription.expired`, `subscription.failed`. + - Action: Updates user subscription state in `users/{uid}` and records event in `subscriptions/{subscription_id}_{timestamp}`. + - Response: `{ success, message }`. + +--- + ## Error Codes - **400:** Bad Request / Missing Fields - **401:** Unauthorized +- **403:** Forbidden / Upgrade Required (`UPGRADE_REQUIRED` tier limit exceeded) - **404:** Not Found - **409:** Conflict / Duplicate Link - **422:** Unprocessable Entity (e.g. Scanned / Image-only PDF) diff --git a/docs/data-model.md b/docs/data-model.md index 96021a7..13c96be 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -49,11 +49,41 @@ Generated server-side for RAG search. A resource can be saved without being sear - `username` (string) - `email` (string) - `share_by_default` (boolean) +- `subscription` (optional object): + - `status` (`'active'` | `'cancelled'` | `'inactive'`) + - `plan` (`'free'` | `'pro'` | `'api'`) + - `subscription_id` (string) + - `customer_email` (string | null) + - `product_id` (string | null) + - `updated_at` (timestamp) - `created_at` (timestamp) - `updated_at` (timestamp) --- +## `users/{uid}/ai_usage/{YYYY-MM}` +- `count` (number) +- `year_month` (string) +- `created_at` (timestamp) +- `updated_at` (timestamp) + +--- + +## `subscriptions` +Root collection for logging DodoPayments webhook events. + +- `id` (doc id: `{subscription_id}_{timestamp}`) +- `subscription_id` (string) +- `user_id` (string) +- `customer_email` (string | null) +- `event_type` (string) +- `status` (`'active'` | `'cancelled'` | `'inactive'`) +- `plan` (`'pro'` | `'api'` | `'free'`) +- `payload` (object) +- `created_at` (timestamp) + +--- + ## `users/{uid}/collections` - `id` (doc id) - `name` (string)