mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 23:48:26 +00:00
feat: add stripe billing for cloud (#81)
* feat: add stripe billing for cloud * fix: mcp tools
This commit is contained in:
parent
92ca34a02f
commit
489fb5934a
@ -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;
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
52
apps/webapp/app/components/ui/progress.tsx
Normal file
52
apps/webapp/app/components/ui/progress.tsx
Normal 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 };
|
||||
120
apps/webapp/app/config/billing.server.ts
Normal file
120
apps/webapp/app/config/billing.server.ts
Normal 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");
|
||||
}
|
||||
@ -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 || "",
|
||||
},
|
||||
});
|
||||
|
||||
393
apps/webapp/app/routes/api.webhooks.stripe.tsx
Normal file
393
apps/webapp/app/routes/api.webhooks.stripe.tsx
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
591
apps/webapp/app/routes/settings.billing.tsx
Normal file
591
apps/webapp/app/routes/settings.billing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
223
apps/webapp/app/services/billing.server.ts
Normal file
223
apps/webapp/app/services/billing.server.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
346
apps/webapp/app/services/stripe.server.ts
Normal file
346
apps/webapp/app/services/stripe.server.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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`, {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "IngestionStatus" ADD VALUE 'NO_CREDITS';
|
||||
@ -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
44
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
12
turbo.json
12
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"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user