feat: add stripe billing for cloud (#81)

* feat: add stripe billing for cloud

* fix: mcp tools
This commit is contained in:
Harshith Mullapudi 2025-10-02 10:58:11 +05:30 committed by GitHub
parent 92ca34a02f
commit 489fb5934a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2389 additions and 221 deletions

View File

@ -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;
});

View File

@ -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<string, string[]>();
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);

View File

@ -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<string, number> = {};
const episodes: Record<string, number> = {};
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<HTMLInputElement>) => {
@ -104,7 +105,7 @@ export function SpaceSearch({
{/* Show search results count */}
{debouncedSearchQuery.trim() && (
<div className="text-muted-foreground shrink-0 text-sm">
{matchingStatements} statement{matchingStatements !== 1 ? "s" : ""}
{matchingEpisodes} episode{matchingEpisodes !== 1 ? "s" : ""}
</div>
)}
</div>

View File

@ -87,33 +87,32 @@ export function LogDetails({ log }: LogDetailsProps) {
// Fetch episode facts when dialog opens and episodeUUID exists
useEffect(() => {
if (facts.length === 0) {
if (log.data?.type === "DOCUMENT" && log.data?.episodes?.length > 0) {
setFactsLoading(true);
// Fetch facts for all episodes in DOCUMENT type
Promise.all(
log.data.episodes.map((episodeId: string) =>
fetch(`/api/v1/episodes/${episodeId}/facts`).then((res) =>
res.json(),
),
if (log.data?.type === "DOCUMENT" && log.data?.episodes?.length > 0) {
setFactsLoading(true);
setFacts([]);
// Fetch facts for all episodes in DOCUMENT type
Promise.all(
log.data.episodes.map((episodeId: string) =>
fetch(`/api/v1/episodes/${episodeId}/facts`).then((res) =>
res.json(),
),
)
.then((results) => {
const allFacts = results.flatMap((result) => result.facts || []);
const allInvalidFacts = results.flatMap(
(result) => result.invalidFacts || [],
);
setFacts(allFacts);
setInvalidFacts(allInvalidFacts);
setFactsLoading(false);
})
.catch(() => {
setFactsLoading(false);
});
} else if (log.episodeUUID) {
setFactsLoading(true);
fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`);
}
),
)
.then((results) => {
const allFacts = results.flatMap((result) => result.facts || []);
const allInvalidFacts = results.flatMap(
(result) => result.invalidFacts || [],
);
setFacts(allFacts);
setInvalidFacts(allInvalidFacts);
setFactsLoading(false);
})
.catch(() => {
setFactsLoading(false);
});
} else if (log.episodeUUID) {
setFactsLoading(true);
fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`);
}
}, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]);
@ -218,7 +217,7 @@ export function LogDetails({ log }: LogDetailsProps) {
<span>Content</span>
</div>
{/* Log Content */}
<div className="mb-4 text-sm break-words whitespace-pre-wrap">
<div className="mb-4 w-full text-sm break-words whitespace-pre-wrap">
<div className="rounded-md">
<Markdown>{log.ingestText}</Markdown>
</div>

View File

@ -49,35 +49,17 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
asChild
onClick={(e) => {
e.stopPropagation();
}}
>
<Button
variant="ghost"
className="mr-0.5 h-8 shrink items-center justify-between gap-2 px-1.5"
>
<div className="flex items-center justify-between gap-2">
<EllipsisVertical size={16} />
</div>
</Button>
</DropdownMenuTrigger>
<Button
variant="secondary"
size="sm"
className="gap-2 rounded"
onClick={(e) => {
setDeleteDialogOpen(true);
}}
>
<Trash size={15} /> Delete
</Button>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
setDeleteDialogOpen(true);
}}
>
<Button variant="link" size="sm" className="gap-2 rounded">
<Trash size={15} /> Delete
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>

View File

@ -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<typeof ProgressPrimitive.Root> & {
color?: string;
segments: ProgressSegment[];
};
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
Props
>(({ className, segments, color, ...props }, ref) => {
const sortedSegments = segments.sort((a, b) => b.value - a.value);
return (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-2 w-full overflow-hidden rounded", className)}
style={{
backgroundColor: `${color}33`,
}}
{...props}
>
{sortedSegments.map((segment, index) => (
<ProgressPrimitive.Indicator
key={index}
className="bg-primary absolute top-0 h-full transition-all"
style={{
width: `${segment.value}%`,
left: "0%",
backgroundColor: `${color}${Math.round(
90 + ((100 - 30) * index) / (sortedSegments.length - 1),
)
.toString(16)
.padStart(2, "0")}`,
zIndex: sortedSegments.length - index,
}}
/>
))}
</ProgressPrimitive.Root>
);
});
Progress.displayName = "Progress";
export { Progress };

View File

@ -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");
}

View File

@ -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 || "",
},
});

View File

@ -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 },
);
}
}

View File

@ -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<typeof loader>();
const fetcher = useFetcher<typeof action>();
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 (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold">Billing</h1>
<p className="text-muted-foreground">
Billing is disabled in self-hosted mode. You have unlimited usage.
</p>
</div>
</div>
);
}
if (!usageSummary) {
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold">Billing</h1>
<p className="text-muted-foreground">
No billing information available.
</p>
</div>
</div>
);
}
return (
<div className="p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold">Billing</h1>
<p className="text-muted-foreground">
Manage your subscription, usage, and billing history
</p>
</div>
{/* Usage Section */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Current Usage</h2>
<div className="grid gap-4 md:grid-cols-3">
{/* Credits Card */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">Credits</span>
<CreditCard className="text-muted-foreground h-4 w-4" />
</div>
<div className="mb-2">
<span className="text-3xl font-bold">
{usageSummary.credits.available}
</span>
<span className="text-muted-foreground">
{" "}
/ {usageSummary.credits.monthly}
</span>
</div>
<Progress
segments={[{ value: 100 - usageSummary.credits.percentageUsed }]}
className="mb-2"
/>
<p className="text-muted-foreground text-xs">
{usageSummary.credits.percentageUsed}% used this period
</p>
</Card>
{/* Usage Breakdown */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Usage Breakdown
</span>
<TrendingUp className="text-muted-foreground h-4 w-4" />
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Episodes</span>
<span className="font-medium">
{usageSummary.usage.episodes}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Searches</span>
<span className="font-medium">
{usageSummary.usage.searches}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Chat</span>
<span className="font-medium">{usageSummary.usage.chat}</span>
</div>
</div>
</Card>
{/* Billing Cycle */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Billing Cycle
</span>
<Calendar className="text-muted-foreground h-4 w-4" />
</div>
<div className="mb-2">
<span className="text-3xl font-bold">
{usageSummary.billingCycle.daysRemaining}
</span>
<span className="text-muted-foreground"> days left</span>
</div>
<p className="text-muted-foreground text-xs">
Resets on{" "}
{new Date(usageSummary.billingCycle.end).toLocaleDateString()}
</p>
</Card>
</div>
{/* Overage Warning */}
{usageSummary.credits.overage > 0 && (
<Card className="mt-4 border-orange-500 bg-orange-50 p-4 dark:bg-orange-950">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<h3 className="font-semibold text-orange-900 dark:text-orange-100">
Overage Usage Detected
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
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.
</>
)}
</p>
</div>
</div>
</Card>
)}
</div>
{/* Plan Section */}
<div className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Plan</h2>
<Button variant="secondary" onClick={() => setShowPlansModal(true)}>
View All Plans
</Button>
</div>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-xl font-bold">{usageSummary.plan.name}</h3>
<Badge
variant={
usageSummary.plan.type === "FREE" ? "secondary" : "default"
}
className="rounded"
>
{usageSummary.plan.type}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
{usageSummary.credits.monthly} credits/month
{usageSummary.overage.enabled && (
<> + ${usageSummary.overage.pricePerCredit}/credit overage</>
)}
</p>
{subscription?.status === "CANCELED" &&
subscription.planType !== "FREE" && (
<div className="mt-3 flex items-start gap-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
<AlertCircle className="mt-0.5 h-4 w-4 text-orange-600 dark:text-orange-400" />
<p className="text-sm text-orange-700 dark:text-orange-300">
Downgrading to FREE plan on{" "}
<strong>
{new Date(
subscription.currentPeriodEnd,
).toLocaleDateString()}
</strong>
. Your current credits and plan will remain active until
then.
</p>
</div>
)}
</div>
</div>
</Card>
</div>
{/* Invoices Section */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Invoices</h2>
{billingHistory.length === 0 ? (
<Card className="p-6">
<p className="text-muted-foreground text-center">No invoices yet</p>
</Card>
) : (
<Card>
<div className="divide-y">
{billingHistory.map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between p-4"
>
<div>
<p className="font-medium">
{new Date(invoice.periodStart).toLocaleDateString()} -{" "}
{new Date(invoice.periodEnd).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<p className="font-bold">
${invoice.totalAmount.toFixed(2)}
</p>
<Badge
variant={
invoice.stripePaymentStatus === "paid"
? "default"
: "destructive"
}
className="rounded"
>
{invoice.stripePaymentStatus || "pending"}
</Badge>
</div>
</div>
))}
</div>
</Card>
)}
</div>
{/* Plans Modal */}
<Dialog open={showPlansModal} onOpenChange={setShowPlansModal}>
<DialogContent className="max-w-5xl p-6">
<DialogHeader>
<DialogTitle>Choose Your CORE Plan</DialogTitle>
<DialogDescription>
Unlock the power of portable memory
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 p-6 md:grid-cols-3">
{/* Free Plan */}
<Card className="p-6">
<div className="mb-4">
<h3 className="text-xl font-bold">Free</h3>
<p className="text-muted-foreground text-sm">
No credit card required
</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$0</span>
<span className="text-muted-foreground">/month</span>
</div>
<ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2">
<span>Memory facts: 5k/mo</span>
</li>
<li className="flex items-start gap-2">
<span>NO USAGE BASED</span>
</li>
</ul>
<Button
className="w-full"
variant="outline"
disabled={
usageSummary.plan.type === "FREE" ||
fetcher.state === "submitting"
}
onClick={() => handlePlanSelect("FREE")}
>
{usageSummary.plan.type === "FREE"
? "Current Plan"
: getPlanAction("FREE") === "downgrade"
? "Downgrade to Free"
: "Try CORE for free"}
</Button>
</Card>
{/* Pro Plan */}
<Card className="border-primary p-6">
<div className="mb-4">
<h3 className="text-xl font-bold">Pro</h3>
<p className="text-muted-foreground text-sm">
For Everyday Productivity
</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$19</span>
<span className="text-muted-foreground">/month</span>
</div>
<ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2">
<span>Memory facts: 25k/mo</span>
</li>
<li className="flex items-start gap-2">
<span>$0.299 /1K ADDITIONAL FACTS</span>
</li>
</ul>
<Button
className="w-full"
disabled={
usageSummary.plan.type === "PRO" ||
fetcher.state === "submitting"
}
onClick={() => handlePlanSelect("PRO")}
>
{usageSummary.plan.type === "PRO"
? "Current Plan"
: getPlanAction("PRO") === "upgrade"
? "Upgrade to PRO"
: "Downgrade to PRO"}
</Button>
</Card>
{/* Max Plan */}
<Card className="p-6">
<div className="mb-4">
<h3 className="text-xl font-bold">Max</h3>
<p className="text-muted-foreground text-sm">
Get the most out of CORE
</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$99</span>
<span className="text-muted-foreground">/month</span>
</div>
<ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2">
<span>Memory facts: 150k/mo</span>
</li>
<li className="flex items-start gap-2">
<span>$0.249 /1K ADDITIONAL FACTS</span>
</li>
</ul>
<Button
className="w-full"
disabled={
usageSummary.plan.type === "MAX" ||
fetcher.state === "submitting"
}
onClick={() => handlePlanSelect("MAX")}
>
{usageSummary.plan.type === "MAX"
? "Current Plan"
: "Upgrade to MAX"}
</Button>
</Card>
</div>
</DialogContent>
</Dialog>
{/* Downgrade Confirmation Dialog */}
<AlertDialog
open={showDowngradeDialog}
onOpenChange={setShowDowngradeDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Downgrade</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to downgrade to the{" "}
<strong>{targetDowngradePlan}</strong> plan? Your current credits
will remain available until the end of your billing period, then
you'll be switched to the {targetDowngradePlan} plan.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDowngrade}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -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 },

View File

@ -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<void> {
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<Subscription> {
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,
},
};
}

View File

@ -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<string> {
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
},
});
}

View File

@ -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);

View File

@ -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`, {

View File

@ -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";

View File

@ -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<WebSearchResult> {
}
}
export const getCreditsForUser = async (
userId: string,
): Promise<UserUsage | null> => {
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<void> {
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<void> {
// 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<boolean> {
// 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;
}

View File

@ -75,7 +75,6 @@ export const memoryTools = [
all: {
type: "boolean",
description: "Get all spaces",
default: true,
},
},
},
@ -90,7 +89,6 @@ export const memoryTools = [
profile: {
type: "boolean",
description: "Get user profile",
default: true,
},
},
},

View File

@ -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",

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "IngestionStatus" ADD VALUE 'NO_CREDITS';

View File

@ -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 {

44
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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"
]
}