From 79d9f2b901b8cee0755bd3fa0983d80b244bf676 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 12 Aug 2025 19:51:48 +0000 Subject: [PATCH] Add Autumn billing integration for usage-based messaging features Co-authored-by: dogesman098 --- README.md | 6 ++ api/autumn-attach.ts | 86 ++++++++++++++++++ api/autumn-check.ts | 86 ++++++++++++++++++ api/autumn-checkout.ts | 86 ++++++++++++++++++ api/autumn-customer.ts | 79 +++++++++++++++++ api/autumn-track.ts | 87 +++++++++++++++++++ autumn.config.ts | 35 ++++++++ convex/messages.ts | 56 ++++++++++++ env-template.txt | 5 ++ scripts/create-env-local.js | 4 + src/components/pricing/CustomPricingTable.tsx | 25 +++++- src/lib/ai.ts | 13 +++ 12 files changed, 564 insertions(+), 4 deletions(-) create mode 100644 api/autumn-attach.ts create mode 100644 api/autumn-check.ts create mode 100644 api/autumn-checkout.ts create mode 100644 api/autumn-customer.ts create mode 100644 api/autumn-track.ts create mode 100644 autumn.config.ts diff --git a/README.md b/README.md index 8aa7205e..e2647be0 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,12 @@ When deploying to production: - `STRIPE_PRICE_ENTERPRISE_MONTH`, `STRIPE_PRICE_ENTERPRISE_YEAR`, - `PUBLIC_ORIGIN`. +## Autumn (backend-only) billing + +- Add `AUTUMN_SECRET_KEY` to your environment (see `env-template.txt` or `scripts/create-env-local.js`). +- Use `/api/autumn-checkout` to initiate checkout and `/api/autumn-attach` to attach if payment method exists. +- Server enforces feature access for chat messages via Autumn and records usage automatically. + ## Development Notes - Vite runs on port 8080 (not 5173) diff --git a/api/autumn-attach.ts b/api/autumn-attach.ts new file mode 100644 index 00000000..844266ef --- /dev/null +++ b/api/autumn-attach.ts @@ -0,0 +1,86 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getBearerOrSessionToken, verifyClerkToken } from './_utils/auth'; + +function withCors(res: VercelResponse, allowOrigin?: string) { + const origin = allowOrigin ?? process.env.PUBLIC_ORIGIN ?? '*'; + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + res.setHeader('Cache-Control', 'private, no-store'); + return res; +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const requestOrigin = req.headers.origin as string | undefined; + let allowedOrigin = process.env.PUBLIC_ORIGIN ?? '*'; + if (requestOrigin) { + const isZapDevDomain = requestOrigin.includes('zapdev.link') || requestOrigin.includes('localhost') || requestOrigin.includes('127.0.0.1'); + if (isZapDevDomain) allowedOrigin = requestOrigin; + } + + if (req.method === 'OPTIONS') { + withCors(res, allowedOrigin); + return res.status(204).end(); + } + + if (req.method !== 'POST') { + return withCors(res, allowedOrigin).status(405).json({ error: 'Method Not Allowed', message: 'Only POST requests are allowed' }); + } + + try { + const token = getBearerOrSessionToken(req); + const issuer = process.env.CLERK_JWT_ISSUER_DOMAIN; + + let authenticatedUserId: string | undefined; + if (token && issuer) { + try { + const audience = process.env.CLERK_JWT_AUDIENCE; + const verified = await verifyClerkToken(token, issuer, audience); + authenticatedUserId = verified?.sub; + } catch (error) { + console.error('Token verification failed:', error); + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + } else { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + + if (!authenticatedUserId) { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'User ID not found in token' }); + } + + const { productId = 'pro' } = req.body || {}; + + const autumnSecret = process.env.AUTUMN_SECRET_KEY; + if (!autumnSecret) { + return withCors(res, allowedOrigin).status(500).json({ error: 'Autumn Misconfigured', message: 'AUTUMN_SECRET_KEY is not set' }); + } + + const apiBase = process.env.AUTUMN_API_BASE || 'https://api.useautumn.com'; + + const upstream = await fetch(`${apiBase}/v1/attach`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${autumnSecret}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: authenticatedUserId, + product_id: productId, + }), + }); + + const payload = await upstream.json().catch(() => ({})); + + if (!upstream.ok) { + console.error('Autumn attach error:', upstream.status, payload); + return withCors(res, allowedOrigin).status(upstream.status).json({ error: 'Attach Error', message: payload?.message || 'Failed to attach Autumn product', details: payload }); + } + + return withCors(res, allowedOrigin).status(200).json(payload?.data ?? payload); + } catch (error) { + console.error('Autumn attach API error:', error); + return withCors(res, allowedOrigin).status(500).json({ error: 'Internal Server Error', message: error instanceof Error ? error.message : 'Unknown error occurred' }); + } +} \ No newline at end of file diff --git a/api/autumn-check.ts b/api/autumn-check.ts new file mode 100644 index 00000000..0528b050 --- /dev/null +++ b/api/autumn-check.ts @@ -0,0 +1,86 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getBearerOrSessionToken, verifyClerkToken } from './_utils/auth'; + +function withCors(res: VercelResponse, allowOrigin?: string) { + const origin = allowOrigin ?? process.env.PUBLIC_ORIGIN ?? '*'; + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + res.setHeader('Cache-Control', 'private, no-store'); + return res; +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const requestOrigin = req.headers.origin as string | undefined; + let allowedOrigin = process.env.PUBLIC_ORIGIN ?? '*'; + if (requestOrigin) { + const isZapDevDomain = requestOrigin.includes('zapdev.link') || requestOrigin.includes('localhost') || requestOrigin.includes('127.0.0.1'); + if (isZapDevDomain) allowedOrigin = requestOrigin; + } + + if (req.method === 'OPTIONS') { + withCors(res, allowedOrigin); + return res.status(204).end(); + } + + if (req.method !== 'POST') { + return withCors(res, allowedOrigin).status(405).json({ error: 'Method Not Allowed', message: 'Only POST requests are allowed' }); + } + + try { + const token = getBearerOrSessionToken(req); + const issuer = process.env.CLERK_JWT_ISSUER_DOMAIN; + + let authenticatedUserId: string | undefined; + if (token && issuer) { + try { + const audience = process.env.CLERK_JWT_AUDIENCE; + const verified = await verifyClerkToken(token, issuer, audience); + authenticatedUserId = verified?.sub; + } catch (error) { + console.error('Token verification failed:', error); + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + } else { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + + if (!authenticatedUserId) { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'User ID not found in token' }); + } + + const { featureId = 'messages' } = req.body || {}; + + const autumnSecret = process.env.AUTUMN_SECRET_KEY; + if (!autumnSecret) { + return withCors(res, allowedOrigin).status(500).json({ error: 'Autumn Misconfigured', message: 'AUTUMN_SECRET_KEY is not set' }); + } + + const apiBase = process.env.AUTUMN_API_BASE || 'https://api.useautumn.com'; + + const upstream = await fetch(`${apiBase}/v1/check`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${autumnSecret}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: authenticatedUserId, + feature_id: featureId, + }), + }); + + const payload = await upstream.json().catch(() => ({})); + + if (!upstream.ok) { + console.error('Autumn check error:', upstream.status, payload); + return withCors(res, allowedOrigin).status(upstream.status).json({ error: 'Check Error', message: payload?.message || 'Failed to check Autumn feature access', details: payload }); + } + + return withCors(res, allowedOrigin).status(200).json(payload?.data ?? payload); + } catch (error) { + console.error('Autumn check API error:', error); + return withCors(res, allowedOrigin).status(500).json({ error: 'Internal Server Error', message: error instanceof Error ? error.message : 'Unknown error occurred' }); + } +} \ No newline at end of file diff --git a/api/autumn-checkout.ts b/api/autumn-checkout.ts new file mode 100644 index 00000000..0e111eb6 --- /dev/null +++ b/api/autumn-checkout.ts @@ -0,0 +1,86 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getBearerOrSessionToken, verifyClerkToken } from './_utils/auth'; + +function withCors(res: VercelResponse, allowOrigin?: string) { + const origin = allowOrigin ?? process.env.PUBLIC_ORIGIN ?? '*'; + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + res.setHeader('Cache-Control', 'private, no-store'); + return res; +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const requestOrigin = req.headers.origin as string | undefined; + let allowedOrigin = process.env.PUBLIC_ORIGIN ?? '*'; + if (requestOrigin) { + const isZapDevDomain = requestOrigin.includes('zapdev.link') || requestOrigin.includes('localhost') || requestOrigin.includes('127.0.0.1'); + if (isZapDevDomain) allowedOrigin = requestOrigin; + } + + if (req.method === 'OPTIONS') { + withCors(res, allowedOrigin); + return res.status(204).end(); + } + + if (req.method !== 'POST') { + return withCors(res, allowedOrigin).status(405).json({ error: 'Method Not Allowed', message: 'Only POST requests are allowed' }); + } + + try { + const token = getBearerOrSessionToken(req); + const issuer = process.env.CLERK_JWT_ISSUER_DOMAIN; + + let authenticatedUserId: string | undefined; + if (token && issuer) { + try { + const audience = process.env.CLERK_JWT_AUDIENCE; + const verified = await verifyClerkToken(token, issuer, audience); + authenticatedUserId = verified?.sub; + } catch (error) { + console.error('Token verification failed:', error); + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + } else { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + + if (!authenticatedUserId) { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'User ID not found in token' }); + } + + const { productId = 'pro' } = req.body || {}; + + const autumnSecret = process.env.AUTUMN_SECRET_KEY; + if (!autumnSecret) { + return withCors(res, allowedOrigin).status(500).json({ error: 'Autumn Misconfigured', message: 'AUTUMN_SECRET_KEY is not set' }); + } + + const apiBase = process.env.AUTUMN_API_BASE || 'https://api.useautumn.com'; + + const upstream = await fetch(`${apiBase}/v1/checkout`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${autumnSecret}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: authenticatedUserId, + product_id: productId, + }), + }); + + const payload = await upstream.json().catch(() => ({})); + + if (!upstream.ok) { + console.error('Autumn checkout error:', upstream.status, payload); + return withCors(res, allowedOrigin).status(upstream.status).json({ error: 'Checkout Error', message: payload?.message || 'Failed to create Autumn checkout', details: payload }); + } + + return withCors(res, allowedOrigin).status(200).json(payload?.data ?? payload); + } catch (error) { + console.error('Autumn checkout API error:', error); + return withCors(res, allowedOrigin).status(500).json({ error: 'Internal Server Error', message: error instanceof Error ? error.message : 'Unknown error occurred' }); + } +} \ No newline at end of file diff --git a/api/autumn-customer.ts b/api/autumn-customer.ts new file mode 100644 index 00000000..378a5652 --- /dev/null +++ b/api/autumn-customer.ts @@ -0,0 +1,79 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getBearerOrSessionToken, verifyClerkToken } from './_utils/auth'; + +function withCors(res: VercelResponse, allowOrigin?: string) { + const origin = allowOrigin ?? process.env.PUBLIC_ORIGIN ?? '*'; + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + res.setHeader('Cache-Control', 'private, no-store'); + return res; +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const requestOrigin = req.headers.origin as string | undefined; + let allowedOrigin = process.env.PUBLIC_ORIGIN ?? '*'; + if (requestOrigin) { + const isZapDevDomain = requestOrigin.includes('zapdev.link') || requestOrigin.includes('localhost') || requestOrigin.includes('127.0.0.1'); + if (isZapDevDomain) allowedOrigin = requestOrigin; + } + + if (req.method === 'OPTIONS') { + withCors(res, allowedOrigin); + return res.status(204).end(); + } + + if (req.method !== 'GET') { + return withCors(res, allowedOrigin).status(405).json({ error: 'Method Not Allowed', message: 'Only GET requests are allowed' }); + } + + try { + const token = getBearerOrSessionToken(req); + const issuer = process.env.CLERK_JWT_ISSUER_DOMAIN; + + let authenticatedUserId: string | undefined; + if (token && issuer) { + try { + const audience = process.env.CLERK_JWT_AUDIENCE; + const verified = await verifyClerkToken(token, issuer, audience); + authenticatedUserId = verified?.sub; + } catch (error) { + console.error('Token verification failed:', error); + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + } else { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + + if (!authenticatedUserId) { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'User ID not found in token' }); + } + + const autumnSecret = process.env.AUTUMN_SECRET_KEY; + if (!autumnSecret) { + return withCors(res, allowedOrigin).status(500).json({ error: 'Autumn Misconfigured', message: 'AUTUMN_SECRET_KEY is not set' }); + } + + const apiBase = process.env.AUTUMN_API_BASE || 'https://api.useautumn.com'; + + const upstream = await fetch(`${apiBase}/v1/customers/${encodeURIComponent(authenticatedUserId)}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${autumnSecret}`, + }, + }); + + const payload = await upstream.json().catch(() => ({})); + + if (!upstream.ok) { + console.error('Autumn customer fetch error:', upstream.status, payload); + return withCors(res, allowedOrigin).status(upstream.status).json({ error: 'Customer Error', message: payload?.message || 'Failed to fetch Autumn customer', details: payload }); + } + + return withCors(res, allowedOrigin).status(200).json(payload?.data ?? payload); + } catch (error) { + console.error('Autumn customer API error:', error); + return withCors(res, allowedOrigin).status(500).json({ error: 'Internal Server Error', message: error instanceof Error ? error.message : 'Unknown error occurred' }); + } +} \ No newline at end of file diff --git a/api/autumn-track.ts b/api/autumn-track.ts new file mode 100644 index 00000000..bed6d37f --- /dev/null +++ b/api/autumn-track.ts @@ -0,0 +1,87 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import { getBearerOrSessionToken, verifyClerkToken } from './_utils/auth'; + +function withCors(res: VercelResponse, allowOrigin?: string) { + const origin = allowOrigin ?? process.env.PUBLIC_ORIGIN ?? '*'; + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + res.setHeader('Cache-Control', 'private, no-store'); + return res; +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const requestOrigin = req.headers.origin as string | undefined; + let allowedOrigin = process.env.PUBLIC_ORIGIN ?? '*'; + if (requestOrigin) { + const isZapDevDomain = requestOrigin.includes('zapdev.link') || requestOrigin.includes('localhost') || requestOrigin.includes('127.0.0.1'); + if (isZapDevDomain) allowedOrigin = requestOrigin; + } + + if (req.method === 'OPTIONS') { + withCors(res, allowedOrigin); + return res.status(204).end(); + } + + if (req.method !== 'POST') { + return withCors(res, allowedOrigin).status(405).json({ error: 'Method Not Allowed', message: 'Only POST requests are allowed' }); + } + + try { + const token = getBearerOrSessionToken(req); + const issuer = process.env.CLERK_JWT_ISSUER_DOMAIN; + + let authenticatedUserId: string | undefined; + if (token && issuer) { + try { + const audience = process.env.CLERK_JWT_AUDIENCE; + const verified = await verifyClerkToken(token, issuer, audience); + authenticatedUserId = verified?.sub; + } catch (error) { + console.error('Token verification failed:', error); + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + } else { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + } + + if (!authenticatedUserId) { + return withCors(res, allowedOrigin).status(401).json({ error: 'Unauthorized', message: 'User ID not found in token' }); + } + + const { featureId = 'messages', value } = req.body || {}; + + const autumnSecret = process.env.AUTUMN_SECRET_KEY; + if (!autumnSecret) { + return withCors(res, allowedOrigin).status(500).json({ error: 'Autumn Misconfigured', message: 'AUTUMN_SECRET_KEY is not set' }); + } + + const apiBase = process.env.AUTUMN_API_BASE || 'https://api.useautumn.com'; + + const upstream = await fetch(`${apiBase}/v1/track`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${autumnSecret}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: authenticatedUserId, + feature_id: featureId, + ...(typeof value === 'number' ? { value } : {}), + }), + }); + + const payload = await upstream.json().catch(() => ({})); + + if (!upstream.ok) { + console.error('Autumn track error:', upstream.status, payload); + return withCors(res, allowedOrigin).status(upstream.status).json({ error: 'Track Error', message: payload?.message || 'Failed to record Autumn usage', details: payload }); + } + + return withCors(res, allowedOrigin).status(200).json(payload?.data ?? payload); + } catch (error) { + console.error('Autumn track API error:', error); + return withCors(res, allowedOrigin).status(500).json({ error: 'Internal Server Error', message: error instanceof Error ? error.message : 'Unknown error occurred' }); + } +} \ No newline at end of file diff --git a/autumn.config.ts b/autumn.config.ts new file mode 100644 index 00000000..f21728ca --- /dev/null +++ b/autumn.config.ts @@ -0,0 +1,35 @@ +import { feature, product, featureItem, priceItem } from "atmn"; + +export const messages = feature({ + id: "messages", + name: "Messages", + type: "single_use", +}); + +export const free = product({ + id: "free", + name: "Free", + items: [ + featureItem({ + feature_id: messages.id, + included_usage: 5, + interval: "month", + }), + ], +}); + +export const pro = product({ + id: "pro", + name: "Pro", + items: [ + featureItem({ + feature_id: messages.id, + included_usage: 100, + interval: "month", + }), + priceItem({ + price: 20, + interval: "month", + }), + ], +}); \ No newline at end of file diff --git a/convex/messages.ts b/convex/messages.ts index 4b8e66d3..c48b9d0f 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -6,6 +6,7 @@ import { enforceRateLimit } from "./rateLimit"; import { enforceAIRateLimit } from "./aiRateLimit"; import DOMPurify from 'dompurify'; + // Security utility functions const generateSecureToken = async (length: number): Promise => { const array = new Uint8Array(length); @@ -254,6 +255,14 @@ export const createMessage = mutation({ // Rate limiting await enforceRateLimit(ctx, "sendMessage"); + // Autumn feature check for user messages + if (args.role === 'user') { + const allowed = await autumnCheckMessagesAllowedDirect(identity.subject); + if (allowed === false) { + throw new Error("No more messages available on your plan"); + } + } + // AI-specific rate limiting for assistant messages if (args.role === "assistant") { // Get user's subscription tier from database @@ -330,6 +339,11 @@ export const createMessage = mutation({ updatedAt: now, }); + // Track usage in Autumn after successful user message creation + if (args.role === 'user') { + await autumnTrackMessageDirect(identity.subject); + } + return messageId; }, }); @@ -979,3 +993,45 @@ export const timingSafeEqual = (a: string, b: string): boolean => { return result === 0; }; + +// Autumn backend calls (server-side) directly to Autumn API +async function autumnCheckMessagesAllowedDirect(customerId: string): Promise { + const secret = process.env.AUTUMN_SECRET_KEY; + if (!secret) return null; + const base = process.env.AUTUMN_API_BASE || 'https://api.useautumn.com'; + try { + const resp = await fetch(`${base}/v1/check`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ customer_id: customerId, feature_id: 'messages' }), + }); + if (!resp.ok) return null; + const json = await resp.json(); + const payload = json?.data ?? json; + if (typeof payload?.allowed === 'boolean') return payload.allowed; + return null; + } catch { + return null; + } +} + +async function autumnTrackMessageDirect(customerId: string): Promise { + const secret = process.env.AUTUMN_SECRET_KEY; + if (!secret) return; + const base = process.env.AUTUMN_API_BASE || 'https://api.useautumn.com'; + try { + await fetch(`${base}/v1/track`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ customer_id: customerId, feature_id: 'messages' }), + }); + } catch { + // best-effort only + } +} diff --git a/env-template.txt b/env-template.txt index d334b3b2..300b0a55 100644 --- a/env-template.txt +++ b/env-template.txt @@ -38,5 +38,10 @@ STRIPE_PRICE_ENTERPRISE_YEAR=price_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Removed Polar billing configuration +# === OPTIONAL: Autumn Billing === +AUTUMN_SECRET_KEY=am_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Optionally override API base (defaults to https://api.useautumn.com) +# AUTUMN_API_BASE=https://api.useautumn.com + # === REQUIRED for server-side fetches to own APIs === PUBLIC_ORIGIN=http://localhost:5173 \ No newline at end of file diff --git a/scripts/create-env-local.js b/scripts/create-env-local.js index 7bb40a14..6fbfc803 100755 --- a/scripts/create-env-local.js +++ b/scripts/create-env-local.js @@ -64,6 +64,10 @@ STRIPE_PRICE_PRO_YEAR= STRIPE_PRICE_ENTERPRISE_MONTH= STRIPE_PRICE_ENTERPRISE_YEAR= +# Autumn Billing Integration (optional) +AUTUMN_SECRET_KEY= +# AUTUMN_API_BASE=https://api.useautumn.com + # Application URL VITE_APP_URL=http://localhost:5173 PUBLIC_ORIGIN=http://localhost:5173 diff --git a/src/components/pricing/CustomPricingTable.tsx b/src/components/pricing/CustomPricingTable.tsx index a477a179..07e57f95 100644 --- a/src/components/pricing/CustomPricingTable.tsx +++ b/src/components/pricing/CustomPricingTable.tsx @@ -175,18 +175,35 @@ const PricingCard = ({ plan, index }: { plan: PricingPlan; index: number }) => { errorData = await response.json(); errorMessage = errorData.message || errorMessage; } catch (e) { - // If response is not JSON, get text const errorText = await response.text(); console.error('Non-JSON error response:', errorText); errorMessage = `Server error: ${response.status} - ${errorText.substring(0, 100)}`; } - const error = new Error(errorMessage); - Sentry.captureException(error, { + // Autumn fallback for Pro monthly + try { + const autumnRes = await fetch('/api/autumn-checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ productId: plan.id }), + }); + if (autumnRes.ok) { + const autumnData = await autumnRes.json(); + if (autumnData?.url) { + window.location.href = autumnData.url; + return; + } + } + } catch (af) { + console.warn('Autumn fallback failed', af); + } + + const err = new Error(errorMessage); + Sentry.captureException(err, { tags: { feature: 'pricing', action: 'checkout_api_error' }, extra: { planId: plan.id, status: response.status, errorData } }); - throw error; + throw err; } const result = await response.json(); diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 8ba48fe9..6ab82f17 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -458,4 +458,17 @@ export async function generateChatTitleFromMessages(messages: Array<{ role: 'use } catch (e) { return 'New chat'; } +} + +export async function recordAutumnMessageUsage(): Promise { + try { + await fetch('/api/autumn-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ featureId: 'messages' }), + credentials: 'include', + }); + } catch { + // ignore + } } \ No newline at end of file