From 3eabd5403214300a08a60a7bde74a2ca10070d80 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 1 Oct 2025 23:46:24 +0530 Subject: [PATCH] feat: add stripe billing for cloud --- .../graph/graph-clustering-visualization.tsx | 15 +- .../app/components/graph/graph-clustering.tsx | 62 +- .../app/components/graph/space-search.tsx | 39 +- .../app/components/logs/log-options.tsx | 38 +- apps/webapp/app/components/ui/progress.tsx | 52 ++ apps/webapp/app/config/billing.server.ts | 120 ++++ apps/webapp/app/lib/neo4j.server.ts | 154 +++-- .../webapp/app/routes/api.webhooks.stripe.tsx | 393 ++++++++++++ apps/webapp/app/routes/settings.billing.tsx | 591 ++++++++++++++++++ apps/webapp/app/routes/settings.tsx | 3 +- apps/webapp/app/services/billing.server.ts | 223 +++++++ apps/webapp/app/services/stripe.server.ts | 346 ++++++++++ apps/webapp/app/trigger/chat/chat.ts | 26 +- apps/webapp/app/trigger/ingest/ingest.ts | 36 ++ apps/webapp/app/trigger/utils/queue.ts | 2 +- apps/webapp/app/trigger/utils/utils.ts | 232 ++++++- apps/webapp/package.json | 2 + .../20251001155414_add_billing/migration.sql | 73 +++ .../migration.sql | 2 + packages/database/prisma/schema.prisma | 90 +++ pnpm-lock.yaml | 44 ++ turbo.json | 12 +- 22 files changed, 2363 insertions(+), 192 deletions(-) create mode 100644 apps/webapp/app/components/ui/progress.tsx create mode 100644 apps/webapp/app/config/billing.server.ts create mode 100644 apps/webapp/app/routes/api.webhooks.stripe.tsx create mode 100644 apps/webapp/app/routes/settings.billing.tsx create mode 100644 apps/webapp/app/services/billing.server.ts create mode 100644 apps/webapp/app/services/stripe.server.ts create mode 100644 packages/database/prisma/migrations/20251001155414_add_billing/migration.sql create mode 100644 packages/database/prisma/migrations/20251001174935_add_no_credits/migration.sql diff --git a/apps/webapp/app/components/graph/graph-clustering-visualization.tsx b/apps/webapp/app/components/graph/graph-clustering-visualization.tsx index 1a6a304..5ef586a 100644 --- a/apps/webapp/app/components/graph/graph-clustering-visualization.tsx +++ b/apps/webapp/app/components/graph/graph-clustering-visualization.tsx @@ -71,21 +71,22 @@ export const GraphClusteringVisualization = forwardRef< // Search filter if (searchQuery.trim()) { // Helper functions for filtering - const isStatementNode = (node: any) => { + const isEpisodeNode = (node: any) => { return ( - node.attributes?.fact || - (node.labels && node.labels.includes("Statement")) + node.attributes?.content || + node.attributes?.episodeUuid || + (node.labels && node.labels.includes("Episode")) ); }; const query = searchQuery.toLowerCase(); filtered = filtered.filter((triplet) => { const sourceMatches = - isStatementNode(triplet.sourceNode) && - triplet.sourceNode.attributes?.fact?.toLowerCase().includes(query); + isEpisodeNode(triplet.sourceNode) && + triplet.sourceNode.attributes?.content?.toLowerCase().includes(query); const targetMatches = - isStatementNode(triplet.targetNode) && - triplet.targetNode.attributes?.fact?.toLowerCase().includes(query); + isEpisodeNode(triplet.targetNode) && + triplet.targetNode.attributes?.content?.toLowerCase().includes(query); return sourceMatches || targetMatches; }); diff --git a/apps/webapp/app/components/graph/graph-clustering.tsx b/apps/webapp/app/components/graph/graph-clustering.tsx index b58378d..8c7dc32 100644 --- a/apps/webapp/app/components/graph/graph-clustering.tsx +++ b/apps/webapp/app/components/graph/graph-clustering.tsx @@ -192,13 +192,13 @@ export const GraphClustering = forwardRef< const nodeData = nodeDataMap.get(node.id) || node; - // Check if this is a Statement node - const isStatementNode = - nodeData.attributes.nodeType === "Statement" || - (nodeData.labels && nodeData.labels.includes("Statement")); + // Check if this is an Episode node + const isEpisodeNode = + nodeData.attributes.nodeType === "Episode" || + (nodeData.labels && nodeData.labels.includes("Episode")); - if (isStatementNode) { - // Statement nodes with cluster IDs use cluster colors + if (isEpisodeNode) { + // Episode nodes with cluster IDs use cluster colors if ( enableClusterColors && nodeData.clusterId && @@ -207,7 +207,7 @@ export const GraphClustering = forwardRef< return clusterColorMap.get(nodeData.clusterId)!; } - // Unclustered statement nodes use a specific light color + // Unclustered episode nodes use a specific light color return themeMode === "dark" ? "#2b9684" : "#54935b"; // Teal/Green from palette } @@ -229,10 +229,10 @@ export const GraphClustering = forwardRef< triplets.forEach((triplet) => { if (!nodeMap.has(triplet.source.id)) { const nodeColor = getNodeColor(triplet.source); - const isStatementNode = - triplet.source.attributes?.nodeType === "Statement" || + const isEpisodeNode = + triplet.source.attributes?.nodeType === "Episode" || (triplet.source.labels && - triplet.source.labels.includes("Statement")); + triplet.source.labels.includes("Episode")); nodeMap.set(triplet.source.id, { id: triplet.source.id, @@ -240,23 +240,23 @@ export const GraphClustering = forwardRef< ? triplet.source.value.split(/\s+/).slice(0, 4).join(" ") + (triplet.source.value.split(/\s+/).length > 4 ? " ..." : "") : "", - size: isStatementNode ? size : size / 2, // Statement nodes slightly larger + size: isEpisodeNode ? size : size / 2, // Episode nodes slightly larger color: nodeColor, x: width, y: height, nodeData: triplet.source, clusterId: triplet.source.clusterId, - // Enhanced border for visual appeal, thicker for Statement nodes + // Enhanced border for visual appeal, thicker for Episode nodes borderSize: 1, borderColor: nodeColor, }); } if (!nodeMap.has(triplet.target.id)) { const nodeColor = getNodeColor(triplet.target); - const isStatementNode = - triplet.target.attributes?.nodeType === "Statement" || + const isEpisodeNode = + triplet.target.attributes?.nodeType === "Episode" || (triplet.target.labels && - triplet.target.labels.includes("Statement")); + triplet.target.labels.includes("Episode")); nodeMap.set(triplet.target.id, { id: triplet.target.id, @@ -264,13 +264,13 @@ export const GraphClustering = forwardRef< ? triplet.target.value.split(/\s+/).slice(0, 4).join(" ") + (triplet.target.value.split(/\s+/).length > 4 ? " ..." : "") : "", - size: isStatementNode ? size : size / 2, // Statement nodes slightly larger + size: isEpisodeNode ? size : size / 2, // Episode nodes slightly larger color: nodeColor, x: width, y: height, nodeData: triplet.target, clusterId: triplet.target.clusterId, - // Enhanced border for visual appeal, thicker for Statement nodes + // Enhanced border for visual appeal, thicker for Episode nodes borderSize: 1, borderColor: nodeColor, }); @@ -294,9 +294,9 @@ export const GraphClustering = forwardRef< target: triplet.target.id, relations: [], relationData: [], - label: "", + label: triplet.relation.value, // Show edge type (predicate for Subject->Object) color: "#0000001A", - labelColor: "#0000001A", + labelColor: "#000000", size: 1, }; } @@ -327,13 +327,13 @@ export const GraphClustering = forwardRef< graph.forEachNode((node) => { const nodeData = graph.getNodeAttribute(node, "nodeData"); const originalColor = getNodeColor(nodeData); - const isStatementNode = - nodeData?.attributes.nodeType === "Statement" || - (nodeData?.labels && nodeData.labels.includes("Statement")); + const isEpisodeNode = + nodeData?.attributes.nodeType === "Episode" || + (nodeData?.labels && nodeData.labels.includes("Episode")); graph.setNodeAttribute(node, "highlighted", false); graph.setNodeAttribute(node, "color", originalColor); - graph.setNodeAttribute(node, "size", isStatementNode ? size : size / 2); + graph.setNodeAttribute(node, "size", isEpisodeNode ? size : size / 2); graph.setNodeAttribute(node, "zIndex", 1); }); graph.forEachEdge((edge) => { @@ -551,19 +551,19 @@ export const GraphClustering = forwardRef< // Apply layout if (graph.order > 0) { - // Strong cluster-based positioning for Statement nodes only + // Strong cluster-based positioning for Episode nodes only const clusterNodeMap = new Map(); const entityNodes: string[] = []; - // Group Statement nodes by their cluster ID, separate Entity nodes + // Group Episode nodes by their cluster ID, separate Entity nodes graph.forEachNode((nodeId, attributes) => { - const isStatementNode = - attributes.nodeData?.nodeType === "Statement" || + const isEpisodeNode = + attributes.nodeData?.nodeType === "Episode" || (attributes.nodeData?.labels && - attributes.nodeData.labels.includes("Statement")); + attributes.nodeData.labels.includes("Episode")); - if (isStatementNode && attributes.clusterId) { - // Statement nodes with cluster IDs go into clusters + if (isEpisodeNode && attributes.clusterId) { + // Episode nodes with cluster IDs go into clusters if (!clusterNodeMap.has(attributes.clusterId)) { clusterNodeMap.set(attributes.clusterId, []); } @@ -640,7 +640,7 @@ export const GraphClustering = forwardRef< } // Position Entity nodes using ForceAtlas2 natural positioning - // They will be positioned by the algorithm based on their connections to Statement nodes + // They will be positioned by the algorithm based on their connections to Episode nodes entityNodes.forEach((nodeId) => { // Give them initial random positions, ForceAtlas2 will adjust based on connections graph.setNodeAttribute(nodeId, "x", Math.random() * width); diff --git a/apps/webapp/app/components/graph/space-search.tsx b/apps/webapp/app/components/graph/space-search.tsx index 031fc35..002530c 100644 --- a/apps/webapp/app/components/graph/space-search.tsx +++ b/apps/webapp/app/components/graph/space-search.tsx @@ -16,7 +16,7 @@ export function SpaceSearch({ triplets, searchQuery, onSearchChange, - placeholder = "Search in statement facts...", + placeholder = "Search in episodes...", }: SpaceSearchProps) { const [inputValue, setInputValue] = useState(searchQuery); @@ -30,41 +30,42 @@ export function SpaceSearch({ } }, [debouncedSearchQuery, searchQuery, onSearchChange]); - // Helper to determine if a node is a statement - const isStatementNode = useCallback((node: any) => { - // Check if node has a fact attribute (indicates it's a statement) + // Helper to determine if a node is an episode + const isEpisodeNode = useCallback((node: any) => { + // Check if node has content attribute (indicates it's an episode) return ( - node.attributes?.fact || - (node.labels && node.labels.includes("Statement")) + node.attributes?.content || + node.attributes?.episodeUuid || + (node.labels && node.labels.includes("Episode")) ); }, []); - // Count statement nodes that match the search - const matchingStatements = useMemo(() => { + // Count episode nodes that match the search + const matchingEpisodes = useMemo(() => { if (!debouncedSearchQuery.trim()) return 0; const query = debouncedSearchQuery.toLowerCase(); - const statements: Record = {}; + const episodes: Record = {}; triplets.forEach((triplet) => { - // Check if source node is a statement and matches + // Check if source node is an episode and matches if ( - isStatementNode(triplet.sourceNode) && - triplet.sourceNode.attributes?.fact?.toLowerCase().includes(query) + isEpisodeNode(triplet.sourceNode) && + triplet.sourceNode.attributes?.content?.toLowerCase().includes(query) ) { - statements[triplet.sourceNode.uuid] = 1; + episodes[triplet.sourceNode.uuid] = 1; } - // Check if target node is a statement and matches + // Check if target node is an episode and matches if ( - isStatementNode(triplet.targetNode) && - triplet.targetNode.attributes?.fact?.toLowerCase().includes(query) + isEpisodeNode(triplet.targetNode) && + triplet.targetNode.attributes?.content?.toLowerCase().includes(query) ) { - statements[triplet.targetNode.uuid] = 1; + episodes[triplet.targetNode.uuid] = 1; } }); - return Object.keys(statements).length; + return Object.keys(episodes).length; }, [triplets, debouncedSearchQuery]); const handleInputChange = (event: React.ChangeEvent) => { @@ -104,7 +105,7 @@ export function SpaceSearch({ {/* Show search results count */} {debouncedSearchQuery.trim() && (
- {matchingStatements} statement{matchingStatements !== 1 ? "s" : ""} + {matchingEpisodes} episode{matchingEpisodes !== 1 ? "s" : ""}
)} diff --git a/apps/webapp/app/components/logs/log-options.tsx b/apps/webapp/app/components/logs/log-options.tsx index 859f938..f5d9b2f 100644 --- a/apps/webapp/app/components/logs/log-options.tsx +++ b/apps/webapp/app/components/logs/log-options.tsx @@ -49,35 +49,17 @@ export const LogOptions = ({ id }: LogOptionsProps) => { return ( <> - - { - e.stopPropagation(); - }} - > - - + - - { - setDeleteDialogOpen(true); - }} - > - - - - diff --git a/apps/webapp/app/components/ui/progress.tsx b/apps/webapp/app/components/ui/progress.tsx new file mode 100644 index 0000000..db16cff --- /dev/null +++ b/apps/webapp/app/components/ui/progress.tsx @@ -0,0 +1,52 @@ +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import * as React from "react"; +import { cn } from "~/lib/utils"; + +interface ProgressSegment { + value: number; +} + +type Props = React.ComponentPropsWithoutRef & { + color?: string; + segments: ProgressSegment[]; +}; + +const Progress = React.forwardRef< + React.ElementRef, + Props +>(({ className, segments, color, ...props }, ref) => { + const sortedSegments = segments.sort((a, b) => b.value - a.value); + + return ( + + {sortedSegments.map((segment, index) => ( + + ))} + + ); +}); + +Progress.displayName = "Progress"; + +export { Progress }; diff --git a/apps/webapp/app/config/billing.server.ts b/apps/webapp/app/config/billing.server.ts new file mode 100644 index 0000000..2505413 --- /dev/null +++ b/apps/webapp/app/config/billing.server.ts @@ -0,0 +1,120 @@ +/** + * Billing Configuration + * + * This file centralizes all billing-related configuration. + * Billing is feature-flagged and can be disabled for self-hosted instances. + */ + +export const BILLING_CONFIG = { + // Feature flag: Enable/disable billing system + // Self-hosted instances can set this to false for unlimited usage + enabled: process.env.ENABLE_BILLING === "true", + + // Stripe configuration (only used if billing is enabled) + stripe: { + secretKey: process.env.STRIPE_SECRET_KEY, + publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, + meterEventName: process.env.STRIPE_METER_EVENT_NAME || "echo_credits_used", + }, + + // Plan configurations + plans: { + free: { + name: "Free", + monthlyCredits: parseInt(process.env.FREE_PLAN_CREDITS || "200", 10), + enableOverage: false, + features: { + episodesPerMonth: 200, + searchesPerMonth: 200, + mcpIntegrations: 3, + }, + }, + pro: { + name: "Pro", + monthlyCredits: parseInt(process.env.PRO_PLAN_CREDITS || "2000", 10), + enableOverage: true, + overagePrice: parseFloat(process.env.PRO_OVERAGE_PRICE || "0.01"), // $0.01 per credit + stripePriceId: process.env.PRO_PLAN_STRIPE_PRICE_ID, + features: { + episodesPerMonth: 2000, + searchesPerMonth: 2000, + mcpIntegrations: -1, // unlimited + prioritySupport: true, + }, + }, + max: { + name: "Max", + monthlyCredits: parseInt(process.env.MAX_PLAN_CREDITS || "10000", 10), + enableOverage: true, + overagePrice: parseFloat(process.env.MAX_OVERAGE_PRICE || "0.008"), // $0.008 per credit (cheaper than pro) + stripePriceId: process.env.MAX_PLAN_STRIPE_PRICE_ID, + features: { + episodesPerMonth: 10000, + searchesPerMonth: 10000, + mcpIntegrations: -1, // unlimited + prioritySupport: true, + customIntegrations: true, + dedicatedSupport: true, + }, + }, + }, + + // Credit costs per operation + creditCosts: { + addEpisode: parseInt(process.env.CREDIT_COST_EPISODE || "1", 10), + search: parseInt(process.env.CREDIT_COST_SEARCH || "1", 10), + chatMessage: parseInt(process.env.CREDIT_COST_CHAT || "1", 10), + }, + + // Billing cycle settings + billingCycle: { + // When to reset credits (1st of each month by default) + resetDay: parseInt(process.env.BILLING_RESET_DAY || "1", 10), + }, +} as const; + +/** + * Get plan configuration by plan type + */ +export function getPlanConfig(planType: "FREE" | "PRO" | "MAX") { + return BILLING_CONFIG.plans[ + planType.toLowerCase() as keyof typeof BILLING_CONFIG.plans + ]; +} + +/** + * Check if billing is enabled + */ +export function isBillingEnabled(): boolean { + return BILLING_CONFIG.enabled; +} + +/** + * Check if Stripe is configured + */ +export function isStripeConfigured(): boolean { + return !!( + BILLING_CONFIG.stripe.secretKey && BILLING_CONFIG.stripe.publishableKey + ); +} + +/** + * Validate billing configuration + */ +export function validateBillingConfig() { + if (!BILLING_CONFIG.enabled) { + console.log( + "ℹ️ Billing is disabled. Running in self-hosted mode with unlimited credits.", + ); + return; + } + + if (!isStripeConfigured()) { + console.warn( + "⚠️ ENABLE_BILLING is true but Stripe is not configured. Billing will not work.", + ); + } + + console.log("✅ Billing is enabled with Stripe integration"); +} diff --git a/apps/webapp/app/lib/neo4j.server.ts b/apps/webapp/app/lib/neo4j.server.ts index 2d044de..21fcc8b 100644 --- a/apps/webapp/app/lib/neo4j.server.ts +++ b/apps/webapp/app/lib/neo4j.server.ts @@ -112,48 +112,45 @@ export const getNodeLinks = async (userId: string) => { export const getClusteredGraphData = async (userId: string) => { const session = driver.session(); try { - // Get the proper reified graph structure: Entity -> Statement -> Entity + // Get the simplified graph structure: Episode, Subject, Object with Predicate as edge const result = await session.run( - `// Get all statements and their entity connections for reified graph - MATCH (s:Statement) + `// Get all statements with their episode and entity connections + MATCH (e:Episode)-[:HAS_PROVENANCE]->(s:Statement) WHERE s.userId = $userId - - // Get all entities connected to each statement + + // Get subject and object entities MATCH (s)-[:HAS_SUBJECT]->(subj:Entity) - MATCH (s)-[:HAS_PREDICATE]->(pred:Entity) + MATCH (s)-[:HAS_PREDICATE]->(pred:Entity) MATCH (s)-[:HAS_OBJECT]->(obj:Entity) - - // Return both Entity->Statement and Statement->Entity relationships - WITH s, subj, pred, obj + + // Return Episode, Subject, and Object as nodes with Predicate as edge label + WITH e, s, subj, pred, obj UNWIND [ - // Subject Entity -> Statement - {source: subj, target: s, type: 'HAS_SUBJECT', isEntityToStatement: true}, - // Statement -> Predicate Entity - {source: s, target: pred, type: 'HAS_PREDICATE', isStatementToEntity: true}, - // Statement -> Object Entity - {source: s, target: obj, type: 'HAS_OBJECT', isStatementToEntity: true} + // Episode -> Subject + {source: e, sourceType: 'Episode', target: subj, targetType: 'Entity', predicate: null}, + // Episode -> Object + {source: e, sourceType: 'Episode', target: obj, targetType: 'Entity', predicate: null}, + // Subject -> Object (with Predicate as edge) + {source: subj, sourceType: 'Entity', target: obj, targetType: 'Entity', predicate: pred.name} ] AS rel - - RETURN DISTINCT + + RETURN DISTINCT rel.source.uuid as sourceUuid, rel.source.name as sourceName, - rel.source.labels as sourceLabels, - rel.source.type as sourceType, - rel.source.properties as sourceProperties, + rel.source.content as sourceContent, + rel.sourceType as sourceNodeType, rel.target.uuid as targetUuid, rel.target.name as targetName, - rel.target.type as targetType, - rel.target.labels as targetLabels, - rel.target.properties as targetProperties, - rel.type as relationshipType, + rel.targetType as targetNodeType, + rel.predicate as predicateLabel, + e.uuid as episodeUuid, + e.content as episodeContent, s.uuid as statementUuid, s.spaceIds as spaceIds, - s.fact as fact, + s.fact as fact, s.invalidAt as invalidAt, s.validAt as validAt, - s.createdAt as createdAt, - rel.isEntityToStatement as isEntityToStatement, - rel.isStatementToEntity as isStatementToEntity`, + s.createdAt as createdAt`, { userId }, ); @@ -163,17 +160,16 @@ export const getClusteredGraphData = async (userId: string) => { result.records.forEach((record) => { const sourceUuid = record.get("sourceUuid"); const sourceName = record.get("sourceName"); - const sourceType = record.get("sourceType"); - const sourceLabels = record.get("sourceLabels") || []; - const sourceProperties = record.get("sourceProperties") || {}; + const sourceContent = record.get("sourceContent"); + const sourceNodeType = record.get("sourceNodeType"); const targetUuid = record.get("targetUuid"); const targetName = record.get("targetName"); - const targetLabels = record.get("targetLabels") || []; - const targetProperties = record.get("targetProperties") || {}; - const targetType = record.get("targetType"); + const targetNodeType = record.get("targetNodeType"); - const relationshipType = record.get("relationshipType"); + const predicateLabel = record.get("predicateLabel"); + const episodeUuid = record.get("episodeUuid"); + const episodeContent = record.get("episodeContent"); const statementUuid = record.get("statementUuid"); const clusterIds = record.get("spaceIds"); const clusterId = clusterIds ? clusterIds[0] : undefined; @@ -183,71 +179,73 @@ export const getClusteredGraphData = async (userId: string) => { const createdAt = record.get("createdAt"); // Create unique edge identifier to avoid duplicates - const edgeKey = `${sourceUuid}-${targetUuid}-${relationshipType}`; + // For Episode->Subject edges, use generic type; for Subject->Object use predicate + const edgeType = predicateLabel || "HAS_SUBJECT"; + const edgeKey = `${sourceUuid}-${targetUuid}-${edgeType}`; if (processedEdges.has(edgeKey)) return; processedEdges.add(edgeKey); - // Determine node types and add appropriate cluster information - const isSourceStatement = - sourceLabels.includes("Statement") || sourceUuid === statementUuid; - const isTargetStatement = - targetLabels.includes("Statement") || targetUuid === statementUuid; + // Build node attributes based on type + const sourceAttributes = + sourceNodeType === "Episode" + ? { + nodeType: "Episode", + content: sourceContent, + episodeUuid: sourceUuid, + clusterId, + } + : { + nodeType: "Entity", + name: sourceName, + clusterId, + }; - // Statement nodes get cluster info, Entity nodes get default attributes - const sourceAttributes = isSourceStatement - ? { - ...sourceProperties, - clusterId, - nodeType: "Statement", - fact, - invalidAt, - validAt, - } - : { - ...sourceProperties, - nodeType: "Entity", - type: sourceType, - name: sourceName, - }; + const targetAttributes = + targetNodeType === "Episode" + ? { + nodeType: "Episode", + content: sourceContent, + episodeUuid: targetUuid, + clusterId, + } + : { + nodeType: "Entity", + name: targetName, + clusterId, + }; - const targetAttributes = isTargetStatement - ? { - ...targetProperties, - clusterId, - nodeType: "Statement", - fact, - invalidAt, - validAt, - } - : { - ...targetProperties, - nodeType: "Entity", - type: targetType, - name: targetName, - }; + // Build display name + const sourceDisplayName = + sourceNodeType === "Episode" + ? sourceContent || episodeUuid + : sourceName || sourceUuid; + const targetDisplayName = + targetNodeType === "Episode" + ? sourceContent || episodeUuid + : targetName || targetUuid; triplets.push({ sourceNode: { uuid: sourceUuid, - labels: sourceLabels, + labels: [sourceNodeType], attributes: sourceAttributes, - name: isSourceStatement ? fact : sourceName || sourceUuid, + name: sourceDisplayName, clusterId, createdAt: createdAt || "", }, edge: { - uuid: `${sourceUuid}-${targetUuid}-${relationshipType}`, - type: relationshipType, + uuid: `${sourceUuid}-${targetUuid}-${edgeType}`, + type: edgeType, source_node_uuid: sourceUuid, target_node_uuid: targetUuid, createdAt: createdAt || "", }, targetNode: { uuid: targetUuid, - labels: targetLabels, + labels: [targetNodeType], attributes: targetAttributes, clusterId, - name: isTargetStatement ? fact : targetName || targetUuid, + name: targetDisplayName, createdAt: createdAt || "", }, }); diff --git a/apps/webapp/app/routes/api.webhooks.stripe.tsx b/apps/webapp/app/routes/api.webhooks.stripe.tsx new file mode 100644 index 0000000..0cef3ac --- /dev/null +++ b/apps/webapp/app/routes/api.webhooks.stripe.tsx @@ -0,0 +1,393 @@ +/** + * Stripe Webhook Handler + * + * Handles Stripe webhook events for subscription management + * This route processes: + * - Subscription creation/updates/cancellations + * - Payment success/failure + * - Usage metering for overage billing + */ + +import type { ActionFunctionArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import Stripe from "stripe"; +import { prisma } from "~/db.server"; +import { BILLING_CONFIG, getPlanConfig } from "~/config/billing.server"; +import { logger } from "~/services/logger.service"; +import type { PlanType } from "@prisma/client"; + +// Initialize Stripe +const stripe = BILLING_CONFIG.stripe.secretKey + ? new Stripe(BILLING_CONFIG.stripe.secretKey) + : null; + +/** + * Verify Stripe webhook signature + */ +function verifyStripeSignature( + payload: string, + signature: string, +): Stripe.Event { + if (!stripe || !BILLING_CONFIG.stripe.webhookSecret) { + throw new Error("Stripe not configured"); + } + + try { + return stripe.webhooks.constructEvent( + payload, + signature, + BILLING_CONFIG.stripe.webhookSecret, + ); + } catch (err) { + throw new Error( + `Webhook signature verification failed: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } +} + +/** + * Handle customer.subscription.created event + */ +async function handleSubscriptionCreated(subscription: any) { + logger.info("Handling subscription.created", { + subscriptionId: subscription.id, + }); + + const customerId = subscription.customer as string; + const priceId = subscription.items.data[0]?.price.id; + + // Determine plan type from price ID + let planType: PlanType = "FREE"; + if (priceId === BILLING_CONFIG.plans.pro.stripePriceId) { + planType = "PRO"; + } else if (priceId === BILLING_CONFIG.plans.max.stripePriceId) { + planType = "MAX"; + } + + const planConfig = getPlanConfig(planType); + + // Find or create subscription record + const existingSubscription = await prisma.subscription.findUnique({ + where: { stripeCustomerId: customerId }, + }); + + if (existingSubscription) { + // Update existing subscription + await prisma.subscription.update({ + where: { id: existingSubscription.id }, + data: { + stripeSubscriptionId: subscription.id, + stripePriceId: priceId, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000, + ), + planType, + status: subscription.status === "active" ? "ACTIVE" : "TRIALING", + monthlyCredits: planConfig.monthlyCredits, + enableUsageBilling: planConfig.enableOverage, + usagePricePerCredit: planConfig.enableOverage + ? planConfig.overagePrice + : null, + currentPeriodStart: new Date(subscription.current_period_start * 1000), + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + }, + }); + + // Reset user credits + const workspace = await prisma.workspace.findUnique({ + where: { id: existingSubscription.workspaceId }, + include: { user: { include: { UserUsage: true } } }, + }); + + if (workspace?.user?.UserUsage) { + await prisma.userUsage.update({ + where: { id: workspace.user.UserUsage.id }, + data: { + availableCredits: planConfig.monthlyCredits, + usedCredits: 0, + overageCredits: 0, + lastResetAt: new Date(), + nextResetAt: new Date(subscription.current_period_end * 1000), + }, + }); + } + } +} + +/** + * Handle customer.subscription.updated event + */ +async function handleSubscriptionUpdated(subscription: any) { + logger.info("Handling subscription.updated", { + subscriptionId: subscription.id, + }); + + const priceId = subscription.items.data[0]?.price.id; + + // Determine plan type from price ID + let planType: PlanType = "FREE"; + if (priceId === BILLING_CONFIG.plans.pro.stripePriceId) { + planType = "PRO"; + } else if (priceId === BILLING_CONFIG.plans.max.stripePriceId) { + planType = "MAX"; + } + + const planConfig = getPlanConfig(planType); + + // Update subscription + const existingSubscription = await prisma.subscription.findUnique({ + where: { stripeSubscriptionId: subscription.id }, + }); + + if (existingSubscription) { + // Determine status - if cancel_at_period_end is true, keep as CANCELED + let subscriptionStatus; + if (subscription.cancel_at_period_end) { + subscriptionStatus = "CANCELED"; + } else if (subscription.status === "active") { + subscriptionStatus = "ACTIVE"; + } else if (subscription.status === "canceled") { + subscriptionStatus = "CANCELED"; + } else if (subscription.status === "past_due") { + subscriptionStatus = "PAST_DUE"; + } else if (subscription.status === "trialing") { + subscriptionStatus = "TRIALING"; + } else if (subscription.status === "paused") { + subscriptionStatus = "PAUSED"; + } else { + subscriptionStatus = "ACTIVE"; + } + + await prisma.subscription.update({ + where: { id: existingSubscription.id }, + data: { + stripePriceId: priceId, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000, + ), + planType, + status: subscriptionStatus, + monthlyCredits: planConfig.monthlyCredits, + enableUsageBilling: planConfig.enableOverage, + usagePricePerCredit: planConfig.enableOverage + ? planConfig.overagePrice + : null, + currentPeriodStart: new Date(subscription.current_period_start * 1000), + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + }, + }); + + // If plan changed, reset credits immediately + if (existingSubscription.planType !== planType) { + const workspace = await prisma.workspace.findUnique({ + where: { id: existingSubscription.workspaceId }, + include: { user: { include: { UserUsage: true } } }, + }); + + if (workspace?.user?.UserUsage) { + await prisma.userUsage.update({ + where: { id: workspace.user.UserUsage.id }, + data: { + availableCredits: planConfig.monthlyCredits, + usedCredits: 0, + overageCredits: 0, + lastResetAt: new Date(), + nextResetAt: new Date(subscription.current_period_end * 1000), + }, + }); + } + } + } +} + +/** + * Handle customer.subscription.deleted event + */ +async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { + logger.info("Handling subscription.deleted", { + subscriptionId: subscription.id, + }); + + const existingSubscription = await prisma.subscription.findUnique({ + where: { stripeSubscriptionId: subscription.id }, + }); + + if (existingSubscription) { + // Downgrade to FREE plan + const freeConfig = getPlanConfig("FREE"); + + await prisma.subscription.update({ + where: { id: existingSubscription.id }, + data: { + planType: "FREE", + status: "ACTIVE", // FREE plan is now active + monthlyCredits: freeConfig.monthlyCredits, + enableUsageBilling: false, + usagePricePerCredit: null, + stripeSubscriptionId: null, + stripePriceId: null, + overageCreditsUsed: 0, + overageAmount: 0, + }, + }); + + // Reset to free tier credits + const workspace = await prisma.workspace.findUnique({ + where: { id: existingSubscription.workspaceId }, + include: { user: { include: { UserUsage: true } } }, + }); + + if (workspace?.user?.UserUsage) { + await prisma.userUsage.update({ + where: { id: workspace.user.UserUsage.id }, + data: { + availableCredits: freeConfig.monthlyCredits, + usedCredits: 0, + overageCredits: 0, + }, + }); + } + } +} + +/** + * Handle invoice.payment_succeeded event + */ +async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) { + logger.info("Handling invoice.payment_succeeded", { invoiceId: invoice.id }); + + const subscriptionId = (invoice as any).subscription as string; + const tax = (invoice as any).tax || 0; + + if (subscriptionId) { + const subscription = await prisma.subscription.findUnique({ + where: { stripeSubscriptionId: subscriptionId }, + }); + + if (subscription) { + // Create billing history record + await prisma.billingHistory.create({ + data: { + subscriptionId: subscription.id, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + monthlyCreditsAllocated: subscription.monthlyCredits, + creditsUsed: 0, // Will be updated from UserUsage + overageCreditsUsed: subscription.overageCreditsUsed, + subscriptionAmount: (invoice.amount_paid - (tax || 0)) / 100, + usageAmount: subscription.overageAmount, + totalAmount: invoice.amount_paid / 100, + stripeInvoiceId: invoice.id, + stripePaymentStatus: invoice.status || "paid", + }, + }); + + // Reset overage tracking after successful payment + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { + overageCreditsUsed: 0, + overageAmount: 0, + }, + }); + } + } +} + +/** + * Handle invoice.payment_failed event + */ +async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { + logger.error("Handling invoice.payment_failed", { invoiceId: invoice.id }); + + const subscriptionId = (invoice as any).subscription as string; + + if (subscriptionId) { + const subscription = await prisma.subscription.findUnique({ + where: { stripeSubscriptionId: subscriptionId }, + }); + + if (subscription) { + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: "PAST_DUE", + }, + }); + + // TODO: Send email notification to user about failed payment + } + } +} + +/** + * Main webhook handler + */ +export async function action({ request }: ActionFunctionArgs) { + // Check if billing is enabled + if (!BILLING_CONFIG.enabled) { + return json({ error: "Billing is not enabled" }, { status: 400 }); + } + + if (!stripe) { + return json({ error: "Stripe not configured" }, { status: 500 }); + } + + const signature = request.headers.get("stripe-signature"); + if (!signature) { + return json({ error: "Missing stripe-signature header" }, { status: 400 }); + } + + const payload = await request.text(); + + try { + const event = verifyStripeSignature(payload, signature); + + logger.info("Received Stripe webhook", { + type: event.type, + id: event.id, + }); + + // Handle different event types + switch (event.type) { + case "customer.subscription.created": + await handleSubscriptionCreated( + event.data.object as Stripe.Subscription, + ); + break; + + case "customer.subscription.updated": + await handleSubscriptionUpdated( + event.data.object as Stripe.Subscription, + ); + break; + + case "customer.subscription.deleted": + await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription, + ); + break; + + case "invoice.payment_succeeded": + await handleInvoicePaymentSucceeded( + event.data.object as Stripe.Invoice, + ); + break; + + case "invoice.payment_failed": + await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice); + break; + + default: + logger.info(`Unhandled webhook event type: ${event.type}`); + } + + return json({ received: true }); + } catch (err) { + logger.error("Webhook handler error", { error: err }); + return json( + { error: err instanceof Error ? err.message : "Webhook handler failed" }, + { status: 400 }, + ); + } +} diff --git a/apps/webapp/app/routes/settings.billing.tsx b/apps/webapp/app/routes/settings.billing.tsx new file mode 100644 index 0000000..ba8491f --- /dev/null +++ b/apps/webapp/app/routes/settings.billing.tsx @@ -0,0 +1,591 @@ +import { + json, + type LoaderFunctionArgs, + type ActionFunctionArgs, +} from "@remix-run/node"; +import { useLoaderData, useFetcher } from "@remix-run/react"; +import { requireUser, requireWorkpace } from "~/services/session.server"; +import { getUsageSummary } from "~/services/billing.server"; +import { + createCheckoutSession, + createBillingPortalSession, + downgradeSubscription, +} from "~/services/stripe.server"; +import { CreditCard, TrendingUp, Calendar, AlertCircle } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; +import { Badge } from "~/components/ui/badge"; +import { Progress } from "~/components/ui/progress"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; +import { prisma } from "~/db.server"; +import { isBillingEnabled } from "~/config/billing.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const workspace = await requireWorkpace(request); + + // Get usage summary + const usageSummary = await getUsageSummary(workspace.id); + + // Get billing history + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId: workspace.id }, + include: { + BillingHistory: { + orderBy: { createdAt: "desc" }, + take: 10, + }, + }, + }); + + const billingEnabled = isBillingEnabled(); + + return json({ + user, + workspace, + usageSummary: usageSummary as any, + billingHistory: subscription?.BillingHistory || [], + billingEnabled, + subscription: subscription + ? { + status: subscription.status, + planType: subscription.planType, + currentPeriodEnd: subscription.currentPeriodEnd, + } + : null, + }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = await requireUser(request); + const workspace = await requireWorkpace(request); + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "upgrade") { + const planType = formData.get("planType") as "PRO" | "MAX"; + const origin = new URL(request.url).origin; + + const checkoutUrl = await createCheckoutSession({ + workspaceId: workspace.id, + planType, + email: user.email, + successUrl: `${origin}/settings/billing?success=true`, + cancelUrl: `${origin}/settings/billing?canceled=true`, + }); + + return json({ checkoutUrl }); + } + + if (intent === "manage") { + const origin = new URL(request.url).origin; + + const portalUrl = await createBillingPortalSession({ + workspaceId: workspace.id, + returnUrl: `${origin}/settings/billing`, + }); + + return json({ portalUrl }); + } + + if (intent === "downgrade") { + const targetPlan = formData.get("planType") as "FREE" | "PRO"; + + // Downgrade subscription - keeps credits until period end, then switches to new plan + await downgradeSubscription({ + workspaceId: workspace.id, + newPlanType: targetPlan, + }); + + return json({ + success: true, + message: `Successfully scheduled downgrade to ${targetPlan}. Your current credits will remain available until the end of your billing period.`, + }); + } + + return json({ error: "Invalid intent" }, { status: 400 }); +}; + +export default function BillingSettings() { + const { usageSummary, billingHistory, billingEnabled, subscription } = + useLoaderData(); + const fetcher = useFetcher(); + const [showPlansModal, setShowPlansModal] = useState(false); + const [showDowngradeDialog, setShowDowngradeDialog] = useState(false); + const [targetDowngradePlan, setTargetDowngradePlan] = useState< + "FREE" | "PRO" | null + >(null); + + // Handle upgrade action + const handleUpgrade = (planType: "PRO" | "MAX") => { + fetcher.submit({ intent: "upgrade", planType }, { method: "POST" }); + }; + + // Handle downgrade action + const handleDowngrade = (planType: "FREE" | "PRO") => { + setTargetDowngradePlan(planType); + setShowDowngradeDialog(true); + }; + + // Confirm and execute downgrade + const confirmDowngrade = () => { + if (targetDowngradePlan) { + fetcher.submit( + { intent: "downgrade", planType: targetDowngradePlan }, + { method: "POST" }, + ); + setShowDowngradeDialog(false); + setTargetDowngradePlan(null); + } + }; + + // Determine if plan is upgrade, downgrade, or current + const getPlanAction = (targetPlan: "FREE" | "PRO" | "MAX") => { + const planOrder = { FREE: 0, PRO: 1, MAX: 2 }; + const currentOrder = + planOrder[usageSummary.plan.type as keyof typeof planOrder]; + const targetOrder = planOrder[targetPlan]; + + if (currentOrder === targetOrder) return "current"; + if (targetOrder > currentOrder) return "upgrade"; + return "downgrade"; + }; + + // Handle plan selection + const handlePlanSelect = (planType: "FREE" | "PRO" | "MAX") => { + const action = getPlanAction(planType); + + if (action === "current") return; + + if (action === "upgrade") { + handleUpgrade(planType as "PRO" | "MAX"); + } else { + handleDowngrade(planType as "FREE" | "PRO"); + } + }; + + // Show success message after downgrade + if (fetcher.data && "success" in fetcher.data && fetcher.data.success) { + // Close modal and show message + setTimeout(() => { + setShowPlansModal(false); + window.location.reload(); // Reload to show updated plan info + }, 1500); + } + + // Redirect to checkout/portal when URL is received + if ( + fetcher.data && + "checkoutUrl" in fetcher.data && + fetcher.data.checkoutUrl + ) { + window.location.href = fetcher.data.checkoutUrl; + } + + if (fetcher.data && "portalUrl" in fetcher.data && fetcher.data.portalUrl) { + window.location.href = fetcher.data.portalUrl; + } + + if (!billingEnabled) { + return ( +
+
+

Billing

+

+ Billing is disabled in self-hosted mode. You have unlimited usage. +

+
+
+ ); + } + + if (!usageSummary) { + return ( +
+
+

Billing

+

+ No billing information available. +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Billing

+

+ Manage your subscription, usage, and billing history +

+
+ + {/* Usage Section */} +
+

Current Usage

+ +
+ {/* Credits Card */} + +
+ Credits + +
+
+ + {usageSummary.credits.available} + + + {" "} + / {usageSummary.credits.monthly} + +
+ +

+ {usageSummary.credits.percentageUsed}% used this period +

+
+ + {/* Usage Breakdown */} + +
+ + Usage Breakdown + + +
+
+
+ Episodes + + {usageSummary.usage.episodes} + +
+
+ Searches + + {usageSummary.usage.searches} + +
+
+ Chat + {usageSummary.usage.chat} +
+
+
+ + {/* Billing Cycle */} + +
+ + Billing Cycle + + +
+
+ + {usageSummary.billingCycle.daysRemaining} + + days left +
+

+ Resets on{" "} + {new Date(usageSummary.billingCycle.end).toLocaleDateString()} +

+
+
+ + {/* Overage Warning */} + {usageSummary.credits.overage > 0 && ( + +
+ +
+

+ Overage Usage Detected +

+

+ You've used {usageSummary.credits.overage} additional credits + beyond your monthly allocation. + {usageSummary.overage.enabled && + usageSummary.overage.pricePerCredit && ( + <> + {" "} + This will cost $ + {( + usageSummary.credits.overage * + usageSummary.overage.pricePerCredit + ).toFixed(2)}{" "} + extra this month. + + )} +

+
+
+
+ )} +
+ + {/* Plan Section */} +
+
+

Plan

+ +
+ + +
+
+
+

{usageSummary.plan.name}

+ + {usageSummary.plan.type} + +
+

+ {usageSummary.credits.monthly} credits/month + {usageSummary.overage.enabled && ( + <> + ${usageSummary.overage.pricePerCredit}/credit overage + )} +

+ {subscription?.status === "CANCELED" && + subscription.planType !== "FREE" && ( +
+ +

+ Downgrading to FREE plan on{" "} + + {new Date( + subscription.currentPeriodEnd, + ).toLocaleDateString()} + + . Your current credits and plan will remain active until + then. +

+
+ )} +
+
+
+
+ + {/* Invoices Section */} +
+

Invoices

+ + {billingHistory.length === 0 ? ( + +

No invoices yet

+
+ ) : ( + +
+ {billingHistory.map((invoice) => ( +
+
+

+ {new Date(invoice.periodStart).toLocaleDateString()} -{" "} + {new Date(invoice.periodEnd).toLocaleDateString()} +

+
+
+

+ ${invoice.totalAmount.toFixed(2)} +

+ + {invoice.stripePaymentStatus || "pending"} + +
+
+ ))} +
+
+ )} +
+ + {/* Plans Modal */} + + + + Choose Your CORE Plan + + Unlock the power of portable memory + + + +
+ {/* Free Plan */} + +
+

Free

+

+ No credit card required +

+
+
+ $0 + /month +
+
    +
  • + Memory facts: 5k/mo +
  • +
  • + NO USAGE BASED +
  • +
+ +
+ + {/* Pro Plan */} + +
+

Pro

+

+ For Everyday Productivity +

+
+
+ $19 + /month +
+
    +
  • + Memory facts: 25k/mo +
  • +
  • + $0.299 /1K ADDITIONAL FACTS +
  • +
+ +
+ + {/* Max Plan */} + +
+

Max

+

+ Get the most out of CORE +

+
+
+ $99 + /month +
+
    +
  • + Memory facts: 150k/mo +
  • +
  • + $0.249 /1K ADDITIONAL FACTS +
  • +
+ +
+
+
+
+ + {/* Downgrade Confirmation Dialog */} + + + + Confirm Downgrade + + Are you sure you want to downgrade to the{" "} + {targetDowngradePlan} plan? Your current credits + will remain available until the end of your billing period, then + you'll be switched to the {targetDowngradePlan} plan. + + + + Cancel + + Continue + + + + +
+ ); +} diff --git a/apps/webapp/app/routes/settings.tsx b/apps/webapp/app/routes/settings.tsx index 433c20e..d30767b 100644 --- a/apps/webapp/app/routes/settings.tsx +++ b/apps/webapp/app/routes/settings.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Code, Webhook, Cable } from "lucide-react"; +import { ArrowLeft, Code, Webhook, Cable, CreditCard } from "lucide-react"; import { Sidebar, @@ -41,6 +41,7 @@ export default function Settings() { const data = { nav: [ // { name: "Workspace", icon: Building }, + { name: "Billing", icon: CreditCard }, { name: "API", icon: Code }, { name: "Webhooks", icon: Webhook }, { name: "MCP", icon: Cable }, diff --git a/apps/webapp/app/services/billing.server.ts b/apps/webapp/app/services/billing.server.ts new file mode 100644 index 0000000..842ebe0 --- /dev/null +++ b/apps/webapp/app/services/billing.server.ts @@ -0,0 +1,223 @@ +/** + * Billing Service + * + * Handles all credit management and billing operations. + * Works in both self-hosted (unlimited) and cloud (metered) modes. + */ + +import { prisma } from "~/db.server"; +import { + BILLING_CONFIG, + isBillingEnabled, + getPlanConfig, +} from "~/config/billing.server"; +import type { PlanType, Subscription } from "@prisma/client"; + +export type CreditOperation = "addEpisode" | "search" | "chatMessage"; + +/** + * Reset monthly credits for a workspace + */ +export async function resetMonthlyCredits(workspaceId: string): Promise { + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + include: { + Subscription: true, + user: { + include: { + UserUsage: true, + }, + }, + }, + }); + + if (!workspace?.Subscription || !workspace.user?.UserUsage) { + throw new Error("Workspace, subscription, or user usage not found"); + } + + const subscription = workspace.Subscription; + const userUsage = workspace.user.UserUsage; + const now = new Date(); + const nextMonth = new Date(now); + nextMonth.setMonth(nextMonth.getMonth() + 1); + + // Create billing history record + await prisma.billingHistory.create({ + data: { + subscriptionId: subscription.id, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + monthlyCreditsAllocated: subscription.monthlyCredits, + creditsUsed: userUsage.usedCredits, + overageCreditsUsed: userUsage.overageCredits, + subscriptionAmount: 0, // TODO: Get from Stripe + usageAmount: subscription.overageAmount, + totalAmount: subscription.overageAmount, + }, + }); + + // Reset credits + await prisma.$transaction([ + prisma.userUsage.update({ + where: { id: userUsage.id }, + data: { + availableCredits: subscription.monthlyCredits, + usedCredits: 0, + overageCredits: 0, + lastResetAt: now, + nextResetAt: nextMonth, + // Reset usage breakdown + episodeCreditsUsed: 0, + searchCreditsUsed: 0, + chatCreditsUsed: 0, + }, + }), + prisma.subscription.update({ + where: { id: subscription.id }, + data: { + currentPeriodStart: now, + currentPeriodEnd: nextMonth, + overageCreditsUsed: 0, + overageAmount: 0, + }, + }), + ]); +} + +/** + * Initialize subscription for a workspace + */ +export async function initializeSubscription( + workspaceId: string, + planType: PlanType = "FREE", +): Promise { + const planConfig = getPlanConfig(planType); + const now = new Date(); + const nextMonth = new Date(now); + nextMonth.setMonth(nextMonth.getMonth() + 1); + + return await prisma.subscription.create({ + data: { + workspaceId, + planType, + monthlyCredits: planConfig.monthlyCredits, + currentPeriodStart: now, + currentPeriodEnd: nextMonth, + enableUsageBilling: planConfig.enableOverage, + usagePricePerCredit: planConfig.enableOverage + ? planConfig.overagePrice + : null, + }, + }); +} + +/** + * Ensure workspace has billing records initialized + */ +async function ensureBillingInitialized(workspaceId: string) { + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + include: { + Subscription: true, + user: { + include: { + UserUsage: true, + }, + }, + }, + }); + + if (!workspace?.user) { + throw new Error("Workspace or user not found"); + } + + // Initialize subscription if missing + if (!workspace.Subscription) { + await initializeSubscription(workspaceId, "FREE"); + } + + // Initialize user usage if missing + if (!workspace.user.UserUsage) { + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId }, + }); + + if (subscription) { + await prisma.userUsage.create({ + data: { + userId: workspace.user.id, + availableCredits: subscription.monthlyCredits, + usedCredits: 0, + overageCredits: 0, + lastResetAt: new Date(), + nextResetAt: subscription.currentPeriodEnd, + episodeCreditsUsed: 0, + searchCreditsUsed: 0, + chatCreditsUsed: 0, + }, + }); + } + } +} + +/** + * Get workspace usage summary + */ +export async function getUsageSummary(workspaceId: string) { + // Ensure billing records exist for existing accounts + await ensureBillingInitialized(workspaceId); + + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + include: { + Subscription: true, + user: { + include: { + UserUsage: true, + }, + }, + }, + }); + + if (!workspace?.Subscription || !workspace.user?.UserUsage) { + return null; + } + + const subscription = workspace.Subscription; + const userUsage = workspace.user.UserUsage; + const planConfig = getPlanConfig(subscription.planType); + + return { + plan: { + type: subscription.planType, + name: planConfig.name, + }, + credits: { + available: userUsage.availableCredits, + used: userUsage.usedCredits, + monthly: subscription.monthlyCredits, + overage: userUsage.overageCredits, + percentageUsed: Math.round( + (userUsage.usedCredits / subscription.monthlyCredits) * 100, + ), + }, + usage: { + episodes: userUsage.episodeCreditsUsed, + searches: userUsage.searchCreditsUsed, + chat: userUsage.chatCreditsUsed, + }, + billingCycle: { + start: subscription.currentPeriodStart, + end: subscription.currentPeriodEnd, + daysRemaining: Math.ceil( + (subscription.currentPeriodEnd.getTime() - Date.now()) / + (1000 * 60 * 60 * 24), + ), + }, + overage: { + enabled: subscription.enableUsageBilling, + pricePerCredit: subscription.usagePricePerCredit, + amount: subscription.overageAmount, + }, + }; +} diff --git a/apps/webapp/app/services/stripe.server.ts b/apps/webapp/app/services/stripe.server.ts new file mode 100644 index 0000000..8ad571f --- /dev/null +++ b/apps/webapp/app/services/stripe.server.ts @@ -0,0 +1,346 @@ +/** + * Stripe Service + * + * Handles Stripe API operations for subscription management + */ + +import Stripe from "stripe"; +import { prisma } from "~/db.server"; +import { + BILLING_CONFIG, + getPlanConfig, + isStripeConfigured, +} from "~/config/billing.server"; + +// Initialize Stripe +const stripe = BILLING_CONFIG.stripe.secretKey + ? new Stripe(BILLING_CONFIG.stripe.secretKey) + : null; + +/** + * Create or retrieve Stripe customer for a workspace + */ +export async function getOrCreateStripeCustomer( + workspaceId: string, + email: string, + name?: string, +): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + // Check if workspace already has a Stripe customer + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId }, + }); + + if (subscription?.stripeCustomerId) { + return subscription.stripeCustomerId; + } + + // Create new Stripe customer + const customer = await stripe.customers.create({ + email, + name, + metadata: { + workspaceId, + }, + }); + + // Update subscription with customer ID + if (subscription) { + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { + stripeCustomerId: customer.id, + }, + }); + } + + return customer.id; +} + +/** + * Create a checkout session for subscription + */ +export async function createCheckoutSession({ + workspaceId, + planType, + email, + successUrl, + cancelUrl, +}: { + workspaceId: string; + planType: "PRO" | "MAX"; + email: string; + successUrl: string; + cancelUrl: string; +}): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + const planConfig = getPlanConfig(planType) as any; + + if (!planConfig.stripePriceId) { + throw new Error(`No Stripe price ID configured for ${planType} plan`); + } + + // Get or create customer + const customerId = await getOrCreateStripeCustomer(workspaceId, email); + + // Create checkout session + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: "subscription", + payment_method_types: ["card"], + line_items: [ + { + price: planConfig.stripePriceId, + quantity: 1, + }, + ], + success_url: successUrl, + cancel_url: cancelUrl, + metadata: { + workspaceId, + planType, + }, + }); + + return session.url!; +} + +/** + * Create a billing portal session for managing subscription + */ +export async function createBillingPortalSession({ + workspaceId, + returnUrl, +}: { + workspaceId: string; + returnUrl: string; +}): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId }, + }); + + if (!subscription?.stripeCustomerId) { + throw new Error("No Stripe customer found for this workspace"); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: subscription.stripeCustomerId, + return_url: returnUrl, + }); + + return session.url; +} + +/** + * Cancel a subscription + */ +export async function cancelSubscription(workspaceId: string): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId }, + }); + + if (!subscription?.stripeSubscriptionId) { + throw new Error("No active subscription found"); + } + + // Cancel at period end + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: "CANCELED", + }, + }); +} + +/** + * Reactivate a canceled subscription + */ +export async function reactivateSubscription( + workspaceId: string, +): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId }, + }); + + if (!subscription?.stripeSubscriptionId) { + throw new Error("No subscription found"); + } + + // Remove cancel at period end + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: false, + }); + + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: "ACTIVE", + }, + }); +} + +/** + * Update subscription to a different plan + */ +export async function updateSubscriptionPlan({ + workspaceId, + newPlanType, +}: { + workspaceId: string; + newPlanType: "PRO" | "MAX"; +}): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId }, + }); + + if (!subscription?.stripeSubscriptionId) { + throw new Error("No active subscription found"); + } + + const planConfig = getPlanConfig(newPlanType) as any; + + if (!planConfig.stripePriceId) { + throw new Error(`No Stripe price ID configured for ${newPlanType} plan`); + } + + // Get the subscription from Stripe + const stripeSubscription = await stripe.subscriptions.retrieve( + subscription.stripeSubscriptionId, + ); + + // Update the subscription item + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + items: [ + { + id: stripeSubscription.items.data[0].id, + price: planConfig.stripePriceId, + }, + ], + proration_behavior: "create_prorations", + }); + + // The webhook will handle updating the database +} + +/** + * Downgrade subscription to a lower plan (keeps credits until period end) + */ +export async function downgradeSubscription({ + workspaceId, + newPlanType, +}: { + workspaceId: string; + newPlanType: "FREE" | "PRO"; +}): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId }, + }); + + if (!subscription?.stripeSubscriptionId) { + throw new Error("No active subscription found"); + } + + // If downgrading to FREE, cancel at period end + if (newPlanType === "FREE") { + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + + await prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: "CANCELED", + }, + }); + return; + } + + // For paid-to-paid downgrades (e.g., MAX to PRO) + const planConfig = getPlanConfig(newPlanType) as any; + + if (!planConfig.stripePriceId) { + throw new Error(`No Stripe price ID configured for ${newPlanType} plan`); + } + + // Get the subscription from Stripe + const stripeSubscription = await stripe.subscriptions.retrieve( + subscription.stripeSubscriptionId, + ); + + // Update subscription without proration, change takes effect at period end + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + items: [ + { + id: stripeSubscription.items.data[0].id, + price: planConfig.stripePriceId, + }, + ], + proration_behavior: "none", + billing_cycle_anchor: "unchanged", + }); + + // The webhook will handle updating the database at period end +} + +/** + * Report usage for metered billing (overage) + * Uses Stripe's new billing meter events API + */ +export async function reportUsage({ + workspaceId, + overageCredits, +}: { + workspaceId: string; + overageCredits: number; +}): Promise { + if (!stripe || !isStripeConfigured()) { + throw new Error("Stripe is not configured"); + } + + const subscription = await prisma.subscription.findUnique({ + where: { workspaceId }, + }); + + if (!subscription?.stripeCustomerId || !subscription.enableUsageBilling) { + return; // No metered billing for this subscription + } + + // Report usage using the new billing meter events API + await stripe.billing.meterEvents.create({ + event_name: BILLING_CONFIG.stripe.meterEventName, + payload: { + value: overageCredits.toString(), + stripe_customer_id: subscription.stripeCustomerId, + }, + }); +} diff --git a/apps/webapp/app/trigger/chat/chat.ts b/apps/webapp/app/trigger/chat/chat.ts index f72b5eb..e86d9a7 100644 --- a/apps/webapp/app/trigger/chat/chat.ts +++ b/apps/webapp/app/trigger/chat/chat.ts @@ -6,15 +6,16 @@ import { MCP } from "../utils/mcp"; import { type HistoryStep } from "../utils/types"; import { createConversationHistoryForAgent, + deductCredits, deletePersonalAccessToken, - getCreditsForUser, getPreviousExecutionHistory, + hasCredits, + InsufficientCreditsError, init, type RunChatPayload, updateConversationHistoryMessage, updateConversationStatus, updateExecutionStep, - updateUserCredits, } from "../utils/utils"; const chatQueue = queue({ @@ -32,11 +33,23 @@ export const chat = task({ queue: chatQueue, init, run: async (payload: RunChatPayload, { init }) => { - const usageCredits = await getCreditsForUser(init?.userId as string); - await updateConversationStatus("running", payload.conversationId); try { + // Check if workspace has sufficient credits before processing + if (init?.conversation.workspaceId) { + const hasSufficientCredits = await hasCredits( + init.conversation.workspaceId, + "chatMessage", + ); + + if (!hasSufficientCredits) { + throw new InsufficientCreditsError( + "Insufficient credits to process chat message. Please upgrade your plan or wait for your credits to reset.", + ); + } + } + const { previousHistory, ...otherData } = payload.context; const { agents = [] } = payload.context; @@ -120,7 +133,10 @@ export const chat = task({ payload.conversationId, ); - usageCredits && (await updateUserCredits(usageCredits, 1)); + // Deduct credits for chat message + if (init?.conversation.workspaceId) { + await deductCredits(init.conversation.workspaceId, "chatMessage"); + } if (init?.tokenId) { await deletePersonalAccessToken(init.tokenId); diff --git a/apps/webapp/app/trigger/ingest/ingest.ts b/apps/webapp/app/trigger/ingest/ingest.ts index 4fa8479..71997b5 100644 --- a/apps/webapp/app/trigger/ingest/ingest.ts +++ b/apps/webapp/app/trigger/ingest/ingest.ts @@ -8,6 +8,7 @@ import { logger } from "~/services/logger.service"; import { triggerSpaceAssignment } from "../spaces/space-assignment"; import { prisma } from "../utils/prisma"; import { EpisodeType } from "@core/types"; +import { deductCredits, hasCredits } from "../utils/utils"; export const IngestBodyRequest = z.object({ episodeBody: z.string(), @@ -40,6 +41,32 @@ export const ingestTask = task({ try { logger.log(`Processing job for user ${payload.userId}`); + // Check if workspace has sufficient credits before processing + const hasSufficientCredits = await hasCredits( + payload.workspaceId, + "addEpisode", + ); + + if (!hasSufficientCredits) { + logger.warn( + `Insufficient credits for workspace ${payload.workspaceId}`, + ); + + await prisma.ingestionQueue.update({ + where: { id: payload.queueId }, + data: { + status: IngestionStatus.NO_CREDITS, + error: + "Insufficient credits. Please upgrade your plan or wait for your credits to reset.", + }, + }); + + return { + success: false, + error: "Insufficient credits", + }; + } + const ingestionQueue = await prisma.ingestionQueue.update({ where: { id: payload.queueId }, data: { @@ -112,6 +139,15 @@ export const ingestTask = task({ }, }); + // Deduct credits for episode creation + if (currentStatus === IngestionStatus.COMPLETED) { + await deductCredits( + payload.workspaceId, + "addEpisode", + finalOutput.statementsCreated, + ); + } + // Trigger space assignment after successful ingestion try { logger.info(`Triggering space assignment after successful ingestion`, { diff --git a/apps/webapp/app/trigger/utils/queue.ts b/apps/webapp/app/trigger/utils/queue.ts index d045ff6..5b62236 100644 --- a/apps/webapp/app/trigger/utils/queue.ts +++ b/apps/webapp/app/trigger/utils/queue.ts @@ -1,4 +1,4 @@ -import { IngestionStatus, PrismaClient } from "@prisma/client"; +import { IngestionStatus } from "@prisma/client"; import { type z } from "zod"; import { type IngestBodyRequest, ingestTask } from "../ingest/ingest"; import { prisma } from "./prisma"; diff --git a/apps/webapp/app/trigger/utils/utils.ts b/apps/webapp/app/trigger/utils/utils.ts index 18a9699..834fc7a 100644 --- a/apps/webapp/app/trigger/utils/utils.ts +++ b/apps/webapp/app/trigger/utils/utils.ts @@ -22,6 +22,7 @@ import nodeCrypto from "node:crypto"; import { customAlphabet, nanoid } from "nanoid"; import { Exa } from "exa-js"; import { prisma } from "./prisma"; +import { BILLING_CONFIG, isBillingEnabled } from "~/config/billing.server"; // Token generation utilities const tokenValueLength = 40; @@ -561,27 +562,216 @@ export async function webSearch(args: WebSearchArgs): Promise { } } -export const getCreditsForUser = async ( - userId: string, -): Promise => { - return await prisma.userUsage.findUnique({ - where: { - userId, - }, - }); -}; +// Credit management functions have been moved to ~/services/billing.server.ts +// Use deductCredits() instead of these functions +export type CreditOperation = "addEpisode" | "search" | "chatMessage"; -export const updateUserCredits = async ( - userUsage: UserUsage, - usedCredits: number, -) => { - return await prisma.userUsage.update({ - where: { - id: userUsage.id, - }, - data: { - availableCredits: userUsage.availableCredits - usedCredits, - usedCredits: userUsage.usedCredits + usedCredits, +export class InsufficientCreditsError extends Error { + constructor(message: string) { + super(message); + this.name = "InsufficientCreditsError"; + } +} + +/** + * Track usage analytics without enforcing limits (for self-hosted) + */ +async function trackUsageAnalytics( + workspaceId: string, + operation: CreditOperation, + amount?: number, +): Promise { + const creditCost = amount || BILLING_CONFIG.creditCosts[operation]; + + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + include: { + user: { + include: { + UserUsage: true, + }, + }, }, }); -}; + + if (!workspace?.user?.UserUsage) { + return; // Silently fail for analytics + } + + const userUsage = workspace.user.UserUsage; + + // Just track usage, don't enforce limits + await prisma.userUsage.update({ + where: { id: userUsage.id }, + data: { + usedCredits: userUsage.usedCredits + creditCost, + ...(operation === "addEpisode" && { + episodeCreditsUsed: userUsage.episodeCreditsUsed + creditCost, + }), + ...(operation === "search" && { + searchCreditsUsed: userUsage.searchCreditsUsed + creditCost, + }), + ...(operation === "chatMessage" && { + chatCreditsUsed: userUsage.chatCreditsUsed + creditCost, + }), + }, + }); +} + +/** + * Deduct credits for a specific operation + */ +export async function deductCredits( + workspaceId: string, + operation: CreditOperation, + amount?: number, +): Promise { + // If billing is disabled (self-hosted), allow unlimited usage + if (!isBillingEnabled()) { + // Still track usage for analytics + await trackUsageAnalytics(workspaceId, operation, amount); + return; + } + + // Get the actual credit cost + const creditCost = amount || BILLING_CONFIG.creditCosts[operation]; + + // Get workspace with subscription and usage + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + include: { + Subscription: true, + user: { + include: { + UserUsage: true, + }, + }, + }, + }); + + if (!workspace || !workspace.user) { + throw new Error("Workspace or user not found"); + } + + const subscription = workspace.Subscription; + const userUsage = workspace.user.UserUsage; + + if (!subscription) { + throw new Error("No subscription found for workspace"); + } + + if (!userUsage) { + throw new Error("No user usage record found"); + } + + // Check if user has available credits + if (userUsage.availableCredits >= creditCost) { + // Deduct from available credits + await prisma.userUsage.update({ + where: { id: userUsage.id }, + data: { + availableCredits: userUsage.availableCredits - creditCost, + usedCredits: userUsage.usedCredits + creditCost, + // Update usage breakdown + ...(operation === "addEpisode" && { + episodeCreditsUsed: userUsage.episodeCreditsUsed + creditCost, + }), + ...(operation === "search" && { + searchCreditsUsed: userUsage.searchCreditsUsed + creditCost, + }), + ...(operation === "chatMessage" && { + chatCreditsUsed: userUsage.chatCreditsUsed + creditCost, + }), + }, + }); + } else { + // Check if usage billing is enabled (Pro/Max plan) + if (subscription.enableUsageBilling) { + // Calculate overage + const overageAmount = creditCost - userUsage.availableCredits; + const cost = overageAmount * (subscription.usagePricePerCredit || 0); + + // Deduct remaining available credits and track overage + await prisma.$transaction([ + prisma.userUsage.update({ + where: { id: userUsage.id }, + data: { + availableCredits: 0, + usedCredits: userUsage.usedCredits + creditCost, + overageCredits: userUsage.overageCredits + overageAmount, + // Update usage breakdown + ...(operation === "addEpisode" && { + episodeCreditsUsed: userUsage.episodeCreditsUsed + creditCost, + }), + ...(operation === "search" && { + searchCreditsUsed: userUsage.searchCreditsUsed + creditCost, + }), + ...(operation === "chatMessage" && { + chatCreditsUsed: userUsage.chatCreditsUsed + creditCost, + }), + }, + }), + prisma.subscription.update({ + where: { id: subscription.id }, + data: { + overageCreditsUsed: subscription.overageCreditsUsed + overageAmount, + overageAmount: subscription.overageAmount + cost, + }, + }), + ]); + } else { + // Free plan - throw error + throw new InsufficientCreditsError( + "Insufficient credits. Please upgrade to Pro or Max plan to continue.", + ); + } + } +} + +/** + * Check if workspace has sufficient credits + */ +export async function hasCredits( + workspaceId: string, + operation: CreditOperation, + amount?: number, +): Promise { + // If billing is disabled, always return true + if (!isBillingEnabled()) { + return true; + } + + const creditCost = amount || BILLING_CONFIG.creditCosts[operation]; + + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + include: { + Subscription: true, + user: { + include: { + UserUsage: true, + }, + }, + }, + }); + + if (!workspace?.user?.UserUsage || !workspace.Subscription) { + return false; + } + + const userUsage = workspace.user.UserUsage; + const subscription = workspace.Subscription; + + // If has available credits, return true + if (userUsage.availableCredits >= creditCost) { + return true; + } + + // If overage is enabled (Pro/Max), return true + if (subscription.enableUsageBilling) { + return true; + } + + // Free plan with no credits left + return false; +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index a69196e..48b96da 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -52,6 +52,7 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.2.7", + "@radix-ui/react-progress": "^1.1.4", "@remix-run/express": "2.16.7", "@remix-run/node": "2.1.0", "@remix-run/react": "2.16.7", @@ -131,6 +132,7 @@ "remix-typedjson": "0.3.1", "remix-utils": "^7.7.0", "sigma": "^3.0.2", + "stripe": "19.0.0", "simple-oauth2": "^5.1.0", "tailwind-merge": "^2.6.0", "tailwind-scrollbar-hide": "^2.0.0", diff --git a/packages/database/prisma/migrations/20251001155414_add_billing/migration.sql b/packages/database/prisma/migrations/20251001155414_add_billing/migration.sql new file mode 100644 index 0000000..6cdf16a --- /dev/null +++ b/packages/database/prisma/migrations/20251001155414_add_billing/migration.sql @@ -0,0 +1,73 @@ +-- CreateEnum +CREATE TYPE "PlanType" AS ENUM ('FREE', 'PRO', 'MAX'); + +-- CreateEnum +CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'CANCELED', 'PAST_DUE', 'TRIALING', 'PAUSED'); + +-- AlterTable +ALTER TABLE "UserUsage" ADD COLUMN "chatCreditsUsed" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "episodeCreditsUsed" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "lastResetAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "nextResetAt" TIMESTAMP(3), +ADD COLUMN "overageCredits" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "searchCreditsUsed" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "stripeCustomerId" TEXT, + "stripeSubscriptionId" TEXT, + "stripePriceId" TEXT, + "stripeCurrentPeriodEnd" TIMESTAMP(3), + "planType" "PlanType" NOT NULL DEFAULT 'FREE', + "status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE', + "monthlyCredits" INTEGER NOT NULL DEFAULT 0, + "currentPeriodStart" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "currentPeriodEnd" TIMESTAMP(3) NOT NULL, + "enableUsageBilling" BOOLEAN NOT NULL DEFAULT false, + "usagePricePerCredit" DOUBLE PRECISION, + "overageCreditsUsed" INTEGER NOT NULL DEFAULT 0, + "overageAmount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "workspaceId" TEXT NOT NULL, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BillingHistory" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "periodStart" TIMESTAMP(3) NOT NULL, + "periodEnd" TIMESTAMP(3) NOT NULL, + "monthlyCreditsAllocated" INTEGER NOT NULL, + "creditsUsed" INTEGER NOT NULL, + "overageCreditsUsed" INTEGER NOT NULL, + "subscriptionAmount" DOUBLE PRECISION NOT NULL, + "usageAmount" DOUBLE PRECISION NOT NULL, + "totalAmount" DOUBLE PRECISION NOT NULL, + "stripeInvoiceId" TEXT, + "stripePaymentStatus" TEXT, + "subscriptionId" TEXT NOT NULL, + + CONSTRAINT "BillingHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_stripeCustomerId_key" ON "Subscription"("stripeCustomerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_stripeSubscriptionId_key" ON "Subscription"("stripeSubscriptionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_workspaceId_key" ON "Subscription"("workspaceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "BillingHistory_stripeInvoiceId_key" ON "BillingHistory"("stripeInvoiceId"); + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BillingHistory" ADD CONSTRAINT "BillingHistory_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20251001174935_add_no_credits/migration.sql b/packages/database/prisma/migrations/20251001174935_add_no_credits/migration.sql new file mode 100644 index 0000000..d0a3365 --- /dev/null +++ b/packages/database/prisma/migrations/20251001174935_add_no_credits/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "IngestionStatus" ADD VALUE 'NO_CREDITS'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index b105f2c..ae4af39 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -574,8 +574,19 @@ model UserUsage { updatedAt DateTime @updatedAt deleted DateTime? + // Current period tracking availableCredits Int @default(0) usedCredits Int @default(0) + overageCredits Int @default(0) // Credits used beyond monthly allocation + + // Last reset tracking + lastResetAt DateTime @default(now()) + nextResetAt DateTime? + + // Usage breakdown (optional analytics) + episodeCreditsUsed Int @default(0) + searchCreditsUsed Int @default(0) + chatCreditsUsed Int @default(0) user User @relation(fields: [userId], references: [id]) userId String @unique @@ -614,6 +625,69 @@ model WebhookDeliveryLog { createdAt DateTime @default(now()) } +model Subscription { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Stripe integration + stripeCustomerId String? @unique + stripeSubscriptionId String? @unique + stripePriceId String? + stripeCurrentPeriodEnd DateTime? + + // Plan details + planType PlanType @default(FREE) + status SubscriptionStatus @default(ACTIVE) + + // Monthly credits allocation + monthlyCredits Int @default(0) + + // Billing cycle tracking + currentPeriodStart DateTime @default(now()) + currentPeriodEnd DateTime + + // Usage-based pricing (for PRO plan) + enableUsageBilling Boolean @default(false) + usagePricePerCredit Float? // Price per credit after monthly quota + + // Overage tracking + overageCreditsUsed Int @default(0) + overageAmount Float @default(0) + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id]) + workspaceId String @unique + BillingHistory BillingHistory[] +} + +model BillingHistory { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + + // Billing period + periodStart DateTime + periodEnd DateTime + + // Credits tracking + monthlyCreditsAllocated Int + creditsUsed Int + overageCreditsUsed Int + + // Charges + subscriptionAmount Float + usageAmount Float // Overage charges + totalAmount Float + + // Stripe integration + stripeInvoiceId String? @unique + stripePaymentStatus String? + + // Relations + subscription Subscription @relation(fields: [subscriptionId], references: [id]) + subscriptionId String +} + model Workspace { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -643,6 +717,21 @@ model Workspace { RecallLog RecallLog[] Space Space[] MCPSession MCPSession[] + Subscription Subscription? +} + +enum PlanType { + FREE + PRO + MAX +} + +enum SubscriptionStatus { + ACTIVE + CANCELED + PAST_DUE + TRIALING + PAUSED } enum AuthenticationMethod { @@ -656,6 +745,7 @@ enum IngestionStatus { COMPLETED FAILED CANCELLED + NO_CREDITS } enum UserType { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c451b1..edc4c22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,6 +367,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.1.14(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.4 + version: 1.1.7(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.0.5 version: 1.2.9(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -634,6 +637,9 @@ importers: simple-oauth2: specifier: ^5.1.0 version: 5.1.0 + stripe: + specifier: 19.0.0 + version: 19.0.0(@types/node@20.19.7) tailwind-merge: specifier: ^2.6.0 version: 2.6.0 @@ -3743,6 +3749,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.0': resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} peerDependencies: @@ -11322,6 +11341,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@19.0.0: + resolution: {integrity: sha512-4HG17r7mui4Awic75DVSFVmH4EIXqNvoo3T2cYrVhcwovQz3gzQIPUiqzLzGcgxdUd9CB8zCntKzm0o63tUBgw==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -16155,6 +16183,16 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.3.7(@types/react@18.2.69) + '@radix-ui/react-progress@1.1.7(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.69 + '@types/react-dom': 18.3.7(@types/react@18.2.69) + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.47)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -25479,6 +25517,12 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@19.0.0(@types/node@20.19.7): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 20.19.7 + strnum@1.1.2: {} strnum@2.1.1: {} diff --git a/turbo.json b/turbo.json index 1ff719a..42dd5ff 100644 --- a/turbo.json +++ b/turbo.json @@ -82,6 +82,16 @@ "EMAIL_TRANSPORT", "AWS_REGION", "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY" + "AWS_SECRET_ACCESS_KEY", + "STRIPE_PUBLISHABLE_KEY", + "STRIPE_SECRET_KEY", + "STRIPE_WEBHOOK_SECRET", + "PRO_PLAN_STRIPE_PRICE_ID", + "MAX_PLAN_STRIPE_PRICE_ID", + "FREE_PLAN_CREDITS", + "PRO_PLAN_CREDITS", + "PRO_OVERAGE_PRICE", + "MAX_PLAN_CREDITS", + "MAX_OVERAGE_PRICE" ] }