mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-22 14:48:27 +00:00
feat: add stripe billing for cloud
This commit is contained in:
parent
f539ad1ecd
commit
3eabd54032
@ -71,21 +71,22 @@ export const GraphClusteringVisualization = forwardRef<
|
|||||||
// Search filter
|
// Search filter
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
// Helper functions for filtering
|
// Helper functions for filtering
|
||||||
const isStatementNode = (node: any) => {
|
const isEpisodeNode = (node: any) => {
|
||||||
return (
|
return (
|
||||||
node.attributes?.fact ||
|
node.attributes?.content ||
|
||||||
(node.labels && node.labels.includes("Statement"))
|
node.attributes?.episodeUuid ||
|
||||||
|
(node.labels && node.labels.includes("Episode"))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter((triplet) => {
|
filtered = filtered.filter((triplet) => {
|
||||||
const sourceMatches =
|
const sourceMatches =
|
||||||
isStatementNode(triplet.sourceNode) &&
|
isEpisodeNode(triplet.sourceNode) &&
|
||||||
triplet.sourceNode.attributes?.fact?.toLowerCase().includes(query);
|
triplet.sourceNode.attributes?.content?.toLowerCase().includes(query);
|
||||||
const targetMatches =
|
const targetMatches =
|
||||||
isStatementNode(triplet.targetNode) &&
|
isEpisodeNode(triplet.targetNode) &&
|
||||||
triplet.targetNode.attributes?.fact?.toLowerCase().includes(query);
|
triplet.targetNode.attributes?.content?.toLowerCase().includes(query);
|
||||||
|
|
||||||
return sourceMatches || targetMatches;
|
return sourceMatches || targetMatches;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -192,13 +192,13 @@ export const GraphClustering = forwardRef<
|
|||||||
|
|
||||||
const nodeData = nodeDataMap.get(node.id) || node;
|
const nodeData = nodeDataMap.get(node.id) || node;
|
||||||
|
|
||||||
// Check if this is a Statement node
|
// Check if this is an Episode node
|
||||||
const isStatementNode =
|
const isEpisodeNode =
|
||||||
nodeData.attributes.nodeType === "Statement" ||
|
nodeData.attributes.nodeType === "Episode" ||
|
||||||
(nodeData.labels && nodeData.labels.includes("Statement"));
|
(nodeData.labels && nodeData.labels.includes("Episode"));
|
||||||
|
|
||||||
if (isStatementNode) {
|
if (isEpisodeNode) {
|
||||||
// Statement nodes with cluster IDs use cluster colors
|
// Episode nodes with cluster IDs use cluster colors
|
||||||
if (
|
if (
|
||||||
enableClusterColors &&
|
enableClusterColors &&
|
||||||
nodeData.clusterId &&
|
nodeData.clusterId &&
|
||||||
@ -207,7 +207,7 @@ export const GraphClustering = forwardRef<
|
|||||||
return clusterColorMap.get(nodeData.clusterId)!;
|
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
|
return themeMode === "dark" ? "#2b9684" : "#54935b"; // Teal/Green from palette
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,10 +229,10 @@ export const GraphClustering = forwardRef<
|
|||||||
triplets.forEach((triplet) => {
|
triplets.forEach((triplet) => {
|
||||||
if (!nodeMap.has(triplet.source.id)) {
|
if (!nodeMap.has(triplet.source.id)) {
|
||||||
const nodeColor = getNodeColor(triplet.source);
|
const nodeColor = getNodeColor(triplet.source);
|
||||||
const isStatementNode =
|
const isEpisodeNode =
|
||||||
triplet.source.attributes?.nodeType === "Statement" ||
|
triplet.source.attributes?.nodeType === "Episode" ||
|
||||||
(triplet.source.labels &&
|
(triplet.source.labels &&
|
||||||
triplet.source.labels.includes("Statement"));
|
triplet.source.labels.includes("Episode"));
|
||||||
|
|
||||||
nodeMap.set(triplet.source.id, {
|
nodeMap.set(triplet.source.id, {
|
||||||
id: 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+/).slice(0, 4).join(" ") +
|
||||||
(triplet.source.value.split(/\s+/).length > 4 ? " ..." : "")
|
(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,
|
color: nodeColor,
|
||||||
x: width,
|
x: width,
|
||||||
y: height,
|
y: height,
|
||||||
nodeData: triplet.source,
|
nodeData: triplet.source,
|
||||||
clusterId: triplet.source.clusterId,
|
clusterId: triplet.source.clusterId,
|
||||||
// Enhanced border for visual appeal, thicker for Statement nodes
|
// Enhanced border for visual appeal, thicker for Episode nodes
|
||||||
borderSize: 1,
|
borderSize: 1,
|
||||||
borderColor: nodeColor,
|
borderColor: nodeColor,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!nodeMap.has(triplet.target.id)) {
|
if (!nodeMap.has(triplet.target.id)) {
|
||||||
const nodeColor = getNodeColor(triplet.target);
|
const nodeColor = getNodeColor(triplet.target);
|
||||||
const isStatementNode =
|
const isEpisodeNode =
|
||||||
triplet.target.attributes?.nodeType === "Statement" ||
|
triplet.target.attributes?.nodeType === "Episode" ||
|
||||||
(triplet.target.labels &&
|
(triplet.target.labels &&
|
||||||
triplet.target.labels.includes("Statement"));
|
triplet.target.labels.includes("Episode"));
|
||||||
|
|
||||||
nodeMap.set(triplet.target.id, {
|
nodeMap.set(triplet.target.id, {
|
||||||
id: 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+/).slice(0, 4).join(" ") +
|
||||||
(triplet.target.value.split(/\s+/).length > 4 ? " ..." : "")
|
(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,
|
color: nodeColor,
|
||||||
x: width,
|
x: width,
|
||||||
y: height,
|
y: height,
|
||||||
nodeData: triplet.target,
|
nodeData: triplet.target,
|
||||||
clusterId: triplet.target.clusterId,
|
clusterId: triplet.target.clusterId,
|
||||||
// Enhanced border for visual appeal, thicker for Statement nodes
|
// Enhanced border for visual appeal, thicker for Episode nodes
|
||||||
borderSize: 1,
|
borderSize: 1,
|
||||||
borderColor: nodeColor,
|
borderColor: nodeColor,
|
||||||
});
|
});
|
||||||
@ -294,9 +294,9 @@ export const GraphClustering = forwardRef<
|
|||||||
target: triplet.target.id,
|
target: triplet.target.id,
|
||||||
relations: [],
|
relations: [],
|
||||||
relationData: [],
|
relationData: [],
|
||||||
label: "",
|
label: triplet.relation.value, // Show edge type (predicate for Subject->Object)
|
||||||
color: "#0000001A",
|
color: "#0000001A",
|
||||||
labelColor: "#0000001A",
|
labelColor: "#000000",
|
||||||
size: 1,
|
size: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -327,13 +327,13 @@ export const GraphClustering = forwardRef<
|
|||||||
graph.forEachNode((node) => {
|
graph.forEachNode((node) => {
|
||||||
const nodeData = graph.getNodeAttribute(node, "nodeData");
|
const nodeData = graph.getNodeAttribute(node, "nodeData");
|
||||||
const originalColor = getNodeColor(nodeData);
|
const originalColor = getNodeColor(nodeData);
|
||||||
const isStatementNode =
|
const isEpisodeNode =
|
||||||
nodeData?.attributes.nodeType === "Statement" ||
|
nodeData?.attributes.nodeType === "Episode" ||
|
||||||
(nodeData?.labels && nodeData.labels.includes("Statement"));
|
(nodeData?.labels && nodeData.labels.includes("Episode"));
|
||||||
|
|
||||||
graph.setNodeAttribute(node, "highlighted", false);
|
graph.setNodeAttribute(node, "highlighted", false);
|
||||||
graph.setNodeAttribute(node, "color", originalColor);
|
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.setNodeAttribute(node, "zIndex", 1);
|
||||||
});
|
});
|
||||||
graph.forEachEdge((edge) => {
|
graph.forEachEdge((edge) => {
|
||||||
@ -551,19 +551,19 @@ export const GraphClustering = forwardRef<
|
|||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
if (graph.order > 0) {
|
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 clusterNodeMap = new Map<string, string[]>();
|
||||||
const entityNodes: 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) => {
|
graph.forEachNode((nodeId, attributes) => {
|
||||||
const isStatementNode =
|
const isEpisodeNode =
|
||||||
attributes.nodeData?.nodeType === "Statement" ||
|
attributes.nodeData?.nodeType === "Episode" ||
|
||||||
(attributes.nodeData?.labels &&
|
(attributes.nodeData?.labels &&
|
||||||
attributes.nodeData.labels.includes("Statement"));
|
attributes.nodeData.labels.includes("Episode"));
|
||||||
|
|
||||||
if (isStatementNode && attributes.clusterId) {
|
if (isEpisodeNode && attributes.clusterId) {
|
||||||
// Statement nodes with cluster IDs go into clusters
|
// Episode nodes with cluster IDs go into clusters
|
||||||
if (!clusterNodeMap.has(attributes.clusterId)) {
|
if (!clusterNodeMap.has(attributes.clusterId)) {
|
||||||
clusterNodeMap.set(attributes.clusterId, []);
|
clusterNodeMap.set(attributes.clusterId, []);
|
||||||
}
|
}
|
||||||
@ -640,7 +640,7 @@ export const GraphClustering = forwardRef<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Position Entity nodes using ForceAtlas2 natural positioning
|
// 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) => {
|
entityNodes.forEach((nodeId) => {
|
||||||
// Give them initial random positions, ForceAtlas2 will adjust based on connections
|
// Give them initial random positions, ForceAtlas2 will adjust based on connections
|
||||||
graph.setNodeAttribute(nodeId, "x", Math.random() * width);
|
graph.setNodeAttribute(nodeId, "x", Math.random() * width);
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export function SpaceSearch({
|
|||||||
triplets,
|
triplets,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
placeholder = "Search in statement facts...",
|
placeholder = "Search in episodes...",
|
||||||
}: SpaceSearchProps) {
|
}: SpaceSearchProps) {
|
||||||
const [inputValue, setInputValue] = useState(searchQuery);
|
const [inputValue, setInputValue] = useState(searchQuery);
|
||||||
|
|
||||||
@ -30,41 +30,42 @@ export function SpaceSearch({
|
|||||||
}
|
}
|
||||||
}, [debouncedSearchQuery, searchQuery, onSearchChange]);
|
}, [debouncedSearchQuery, searchQuery, onSearchChange]);
|
||||||
|
|
||||||
// Helper to determine if a node is a statement
|
// Helper to determine if a node is an episode
|
||||||
const isStatementNode = useCallback((node: any) => {
|
const isEpisodeNode = useCallback((node: any) => {
|
||||||
// Check if node has a fact attribute (indicates it's a statement)
|
// Check if node has content attribute (indicates it's an episode)
|
||||||
return (
|
return (
|
||||||
node.attributes?.fact ||
|
node.attributes?.content ||
|
||||||
(node.labels && node.labels.includes("Statement"))
|
node.attributes?.episodeUuid ||
|
||||||
|
(node.labels && node.labels.includes("Episode"))
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Count statement nodes that match the search
|
// Count episode nodes that match the search
|
||||||
const matchingStatements = useMemo(() => {
|
const matchingEpisodes = useMemo(() => {
|
||||||
if (!debouncedSearchQuery.trim()) return 0;
|
if (!debouncedSearchQuery.trim()) return 0;
|
||||||
|
|
||||||
const query = debouncedSearchQuery.toLowerCase();
|
const query = debouncedSearchQuery.toLowerCase();
|
||||||
const statements: Record<string, number> = {};
|
const episodes: Record<string, number> = {};
|
||||||
|
|
||||||
triplets.forEach((triplet) => {
|
triplets.forEach((triplet) => {
|
||||||
// Check if source node is a statement and matches
|
// Check if source node is an episode and matches
|
||||||
if (
|
if (
|
||||||
isStatementNode(triplet.sourceNode) &&
|
isEpisodeNode(triplet.sourceNode) &&
|
||||||
triplet.sourceNode.attributes?.fact?.toLowerCase().includes(query)
|
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 (
|
if (
|
||||||
isStatementNode(triplet.targetNode) &&
|
isEpisodeNode(triplet.targetNode) &&
|
||||||
triplet.targetNode.attributes?.fact?.toLowerCase().includes(query)
|
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]);
|
}, [triplets, debouncedSearchQuery]);
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -104,7 +105,7 @@ export function SpaceSearch({
|
|||||||
{/* Show search results count */}
|
{/* Show search results count */}
|
||||||
{debouncedSearchQuery.trim() && (
|
{debouncedSearchQuery.trim() && (
|
||||||
<div className="text-muted-foreground shrink-0 text-sm">
|
<div className="text-muted-foreground shrink-0 text-sm">
|
||||||
{matchingStatements} statement{matchingStatements !== 1 ? "s" : ""}
|
{matchingEpisodes} episode{matchingEpisodes !== 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -49,35 +49,17 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<Button
|
||||||
<DropdownMenuTrigger
|
variant="secondary"
|
||||||
asChild
|
size="sm"
|
||||||
onClick={(e) => {
|
className="gap-2 rounded"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
}}
|
setDeleteDialogOpen(true);
|
||||||
>
|
}}
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<Trash size={15} /> Delete
|
||||||
className="mr-0.5 h-8 shrink items-center justify-between gap-2 px-1.5"
|
</Button>
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<EllipsisVertical size={16} />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<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}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<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) => {
|
export const getClusteredGraphData = async (userId: string) => {
|
||||||
const session = driver.session();
|
const session = driver.session();
|
||||||
try {
|
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(
|
const result = await session.run(
|
||||||
`// Get all statements and their entity connections for reified graph
|
`// Get all statements with their episode and entity connections
|
||||||
MATCH (s:Statement)
|
MATCH (e:Episode)-[:HAS_PROVENANCE]->(s:Statement)
|
||||||
WHERE s.userId = $userId
|
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_SUBJECT]->(subj:Entity)
|
||||||
MATCH (s)-[:HAS_PREDICATE]->(pred:Entity)
|
MATCH (s)-[:HAS_PREDICATE]->(pred:Entity)
|
||||||
MATCH (s)-[:HAS_OBJECT]->(obj:Entity)
|
MATCH (s)-[:HAS_OBJECT]->(obj:Entity)
|
||||||
|
|
||||||
// Return both Entity->Statement and Statement->Entity relationships
|
// Return Episode, Subject, and Object as nodes with Predicate as edge label
|
||||||
WITH s, subj, pred, obj
|
WITH e, s, subj, pred, obj
|
||||||
UNWIND [
|
UNWIND [
|
||||||
// Subject Entity -> Statement
|
// Episode -> Subject
|
||||||
{source: subj, target: s, type: 'HAS_SUBJECT', isEntityToStatement: true},
|
{source: e, sourceType: 'Episode', target: subj, targetType: 'Entity', predicate: null},
|
||||||
// Statement -> Predicate Entity
|
// Episode -> Object
|
||||||
{source: s, target: pred, type: 'HAS_PREDICATE', isStatementToEntity: true},
|
{source: e, sourceType: 'Episode', target: obj, targetType: 'Entity', predicate: null},
|
||||||
// Statement -> Object Entity
|
// Subject -> Object (with Predicate as edge)
|
||||||
{source: s, target: obj, type: 'HAS_OBJECT', isStatementToEntity: true}
|
{source: subj, sourceType: 'Entity', target: obj, targetType: 'Entity', predicate: pred.name}
|
||||||
] AS rel
|
] AS rel
|
||||||
|
|
||||||
RETURN DISTINCT
|
RETURN DISTINCT
|
||||||
rel.source.uuid as sourceUuid,
|
rel.source.uuid as sourceUuid,
|
||||||
rel.source.name as sourceName,
|
rel.source.name as sourceName,
|
||||||
rel.source.labels as sourceLabels,
|
rel.source.content as sourceContent,
|
||||||
rel.source.type as sourceType,
|
rel.sourceType as sourceNodeType,
|
||||||
rel.source.properties as sourceProperties,
|
|
||||||
rel.target.uuid as targetUuid,
|
rel.target.uuid as targetUuid,
|
||||||
rel.target.name as targetName,
|
rel.target.name as targetName,
|
||||||
rel.target.type as targetType,
|
rel.targetType as targetNodeType,
|
||||||
rel.target.labels as targetLabels,
|
rel.predicate as predicateLabel,
|
||||||
rel.target.properties as targetProperties,
|
e.uuid as episodeUuid,
|
||||||
rel.type as relationshipType,
|
e.content as episodeContent,
|
||||||
s.uuid as statementUuid,
|
s.uuid as statementUuid,
|
||||||
s.spaceIds as spaceIds,
|
s.spaceIds as spaceIds,
|
||||||
s.fact as fact,
|
s.fact as fact,
|
||||||
s.invalidAt as invalidAt,
|
s.invalidAt as invalidAt,
|
||||||
s.validAt as validAt,
|
s.validAt as validAt,
|
||||||
s.createdAt as createdAt,
|
s.createdAt as createdAt`,
|
||||||
rel.isEntityToStatement as isEntityToStatement,
|
|
||||||
rel.isStatementToEntity as isStatementToEntity`,
|
|
||||||
{ userId },
|
{ userId },
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -163,17 +160,16 @@ export const getClusteredGraphData = async (userId: string) => {
|
|||||||
result.records.forEach((record) => {
|
result.records.forEach((record) => {
|
||||||
const sourceUuid = record.get("sourceUuid");
|
const sourceUuid = record.get("sourceUuid");
|
||||||
const sourceName = record.get("sourceName");
|
const sourceName = record.get("sourceName");
|
||||||
const sourceType = record.get("sourceType");
|
const sourceContent = record.get("sourceContent");
|
||||||
const sourceLabels = record.get("sourceLabels") || [];
|
const sourceNodeType = record.get("sourceNodeType");
|
||||||
const sourceProperties = record.get("sourceProperties") || {};
|
|
||||||
|
|
||||||
const targetUuid = record.get("targetUuid");
|
const targetUuid = record.get("targetUuid");
|
||||||
const targetName = record.get("targetName");
|
const targetName = record.get("targetName");
|
||||||
const targetLabels = record.get("targetLabels") || [];
|
const targetNodeType = record.get("targetNodeType");
|
||||||
const targetProperties = record.get("targetProperties") || {};
|
|
||||||
const targetType = record.get("targetType");
|
|
||||||
|
|
||||||
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 statementUuid = record.get("statementUuid");
|
||||||
const clusterIds = record.get("spaceIds");
|
const clusterIds = record.get("spaceIds");
|
||||||
const clusterId = clusterIds ? clusterIds[0] : undefined;
|
const clusterId = clusterIds ? clusterIds[0] : undefined;
|
||||||
@ -183,71 +179,73 @@ export const getClusteredGraphData = async (userId: string) => {
|
|||||||
const createdAt = record.get("createdAt");
|
const createdAt = record.get("createdAt");
|
||||||
|
|
||||||
// Create unique edge identifier to avoid duplicates
|
// 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;
|
if (processedEdges.has(edgeKey)) return;
|
||||||
processedEdges.add(edgeKey);
|
processedEdges.add(edgeKey);
|
||||||
|
|
||||||
// Determine node types and add appropriate cluster information
|
// Build node attributes based on type
|
||||||
const isSourceStatement =
|
const sourceAttributes =
|
||||||
sourceLabels.includes("Statement") || sourceUuid === statementUuid;
|
sourceNodeType === "Episode"
|
||||||
const isTargetStatement =
|
? {
|
||||||
targetLabels.includes("Statement") || targetUuid === statementUuid;
|
nodeType: "Episode",
|
||||||
|
content: sourceContent,
|
||||||
|
episodeUuid: sourceUuid,
|
||||||
|
clusterId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
nodeType: "Entity",
|
||||||
|
name: sourceName,
|
||||||
|
clusterId,
|
||||||
|
};
|
||||||
|
|
||||||
// Statement nodes get cluster info, Entity nodes get default attributes
|
const targetAttributes =
|
||||||
const sourceAttributes = isSourceStatement
|
targetNodeType === "Episode"
|
||||||
? {
|
? {
|
||||||
...sourceProperties,
|
nodeType: "Episode",
|
||||||
clusterId,
|
content: sourceContent,
|
||||||
nodeType: "Statement",
|
episodeUuid: targetUuid,
|
||||||
fact,
|
clusterId,
|
||||||
invalidAt,
|
}
|
||||||
validAt,
|
: {
|
||||||
}
|
nodeType: "Entity",
|
||||||
: {
|
name: targetName,
|
||||||
...sourceProperties,
|
clusterId,
|
||||||
nodeType: "Entity",
|
};
|
||||||
type: sourceType,
|
|
||||||
name: sourceName,
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetAttributes = isTargetStatement
|
// Build display name
|
||||||
? {
|
const sourceDisplayName =
|
||||||
...targetProperties,
|
sourceNodeType === "Episode"
|
||||||
clusterId,
|
? sourceContent || episodeUuid
|
||||||
nodeType: "Statement",
|
: sourceName || sourceUuid;
|
||||||
fact,
|
const targetDisplayName =
|
||||||
invalidAt,
|
targetNodeType === "Episode"
|
||||||
validAt,
|
? sourceContent || episodeUuid
|
||||||
}
|
: targetName || targetUuid;
|
||||||
: {
|
|
||||||
...targetProperties,
|
|
||||||
nodeType: "Entity",
|
|
||||||
type: targetType,
|
|
||||||
name: targetName,
|
|
||||||
};
|
|
||||||
|
|
||||||
triplets.push({
|
triplets.push({
|
||||||
sourceNode: {
|
sourceNode: {
|
||||||
uuid: sourceUuid,
|
uuid: sourceUuid,
|
||||||
labels: sourceLabels,
|
labels: [sourceNodeType],
|
||||||
attributes: sourceAttributes,
|
attributes: sourceAttributes,
|
||||||
name: isSourceStatement ? fact : sourceName || sourceUuid,
|
name: sourceDisplayName,
|
||||||
clusterId,
|
clusterId,
|
||||||
createdAt: createdAt || "",
|
createdAt: createdAt || "",
|
||||||
},
|
},
|
||||||
edge: {
|
edge: {
|
||||||
uuid: `${sourceUuid}-${targetUuid}-${relationshipType}`,
|
uuid: `${sourceUuid}-${targetUuid}-${edgeType}`,
|
||||||
type: relationshipType,
|
type: edgeType,
|
||||||
source_node_uuid: sourceUuid,
|
source_node_uuid: sourceUuid,
|
||||||
target_node_uuid: targetUuid,
|
target_node_uuid: targetUuid,
|
||||||
createdAt: createdAt || "",
|
createdAt: createdAt || "",
|
||||||
},
|
},
|
||||||
targetNode: {
|
targetNode: {
|
||||||
uuid: targetUuid,
|
uuid: targetUuid,
|
||||||
labels: targetLabels,
|
labels: [targetNodeType],
|
||||||
attributes: targetAttributes,
|
attributes: targetAttributes,
|
||||||
clusterId,
|
clusterId,
|
||||||
name: isTargetStatement ? fact : targetName || targetUuid,
|
name: targetDisplayName,
|
||||||
createdAt: createdAt || "",
|
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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@ -41,6 +41,7 @@ export default function Settings() {
|
|||||||
const data = {
|
const data = {
|
||||||
nav: [
|
nav: [
|
||||||
// { name: "Workspace", icon: Building },
|
// { name: "Workspace", icon: Building },
|
||||||
|
{ name: "Billing", icon: CreditCard },
|
||||||
{ name: "API", icon: Code },
|
{ name: "API", icon: Code },
|
||||||
{ name: "Webhooks", icon: Webhook },
|
{ name: "Webhooks", icon: Webhook },
|
||||||
{ name: "MCP", icon: Cable },
|
{ 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 { type HistoryStep } from "../utils/types";
|
||||||
import {
|
import {
|
||||||
createConversationHistoryForAgent,
|
createConversationHistoryForAgent,
|
||||||
|
deductCredits,
|
||||||
deletePersonalAccessToken,
|
deletePersonalAccessToken,
|
||||||
getCreditsForUser,
|
|
||||||
getPreviousExecutionHistory,
|
getPreviousExecutionHistory,
|
||||||
|
hasCredits,
|
||||||
|
InsufficientCreditsError,
|
||||||
init,
|
init,
|
||||||
type RunChatPayload,
|
type RunChatPayload,
|
||||||
updateConversationHistoryMessage,
|
updateConversationHistoryMessage,
|
||||||
updateConversationStatus,
|
updateConversationStatus,
|
||||||
updateExecutionStep,
|
updateExecutionStep,
|
||||||
updateUserCredits,
|
|
||||||
} from "../utils/utils";
|
} from "../utils/utils";
|
||||||
|
|
||||||
const chatQueue = queue({
|
const chatQueue = queue({
|
||||||
@ -32,11 +33,23 @@ export const chat = task({
|
|||||||
queue: chatQueue,
|
queue: chatQueue,
|
||||||
init,
|
init,
|
||||||
run: async (payload: RunChatPayload, { init }) => {
|
run: async (payload: RunChatPayload, { init }) => {
|
||||||
const usageCredits = await getCreditsForUser(init?.userId as string);
|
|
||||||
|
|
||||||
await updateConversationStatus("running", payload.conversationId);
|
await updateConversationStatus("running", payload.conversationId);
|
||||||
|
|
||||||
try {
|
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 { previousHistory, ...otherData } = payload.context;
|
||||||
|
|
||||||
const { agents = [] } = payload.context;
|
const { agents = [] } = payload.context;
|
||||||
@ -120,7 +133,10 @@ export const chat = task({
|
|||||||
payload.conversationId,
|
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) {
|
if (init?.tokenId) {
|
||||||
await deletePersonalAccessToken(init.tokenId);
|
await deletePersonalAccessToken(init.tokenId);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { logger } from "~/services/logger.service";
|
|||||||
import { triggerSpaceAssignment } from "../spaces/space-assignment";
|
import { triggerSpaceAssignment } from "../spaces/space-assignment";
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
import { EpisodeType } from "@core/types";
|
import { EpisodeType } from "@core/types";
|
||||||
|
import { deductCredits, hasCredits } from "../utils/utils";
|
||||||
|
|
||||||
export const IngestBodyRequest = z.object({
|
export const IngestBodyRequest = z.object({
|
||||||
episodeBody: z.string(),
|
episodeBody: z.string(),
|
||||||
@ -40,6 +41,32 @@ export const ingestTask = task({
|
|||||||
try {
|
try {
|
||||||
logger.log(`Processing job for user ${payload.userId}`);
|
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({
|
const ingestionQueue = await prisma.ingestionQueue.update({
|
||||||
where: { id: payload.queueId },
|
where: { id: payload.queueId },
|
||||||
data: {
|
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
|
// Trigger space assignment after successful ingestion
|
||||||
try {
|
try {
|
||||||
logger.info(`Triggering space assignment after successful ingestion`, {
|
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 z } from "zod";
|
||||||
import { type IngestBodyRequest, ingestTask } from "../ingest/ingest";
|
import { type IngestBodyRequest, ingestTask } from "../ingest/ingest";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import nodeCrypto from "node:crypto";
|
|||||||
import { customAlphabet, nanoid } from "nanoid";
|
import { customAlphabet, nanoid } from "nanoid";
|
||||||
import { Exa } from "exa-js";
|
import { Exa } from "exa-js";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
import { BILLING_CONFIG, isBillingEnabled } from "~/config/billing.server";
|
||||||
|
|
||||||
// Token generation utilities
|
// Token generation utilities
|
||||||
const tokenValueLength = 40;
|
const tokenValueLength = 40;
|
||||||
@ -561,27 +562,216 @@ export async function webSearch(args: WebSearchArgs): Promise<WebSearchResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCreditsForUser = async (
|
// Credit management functions have been moved to ~/services/billing.server.ts
|
||||||
userId: string,
|
// Use deductCredits() instead of these functions
|
||||||
): Promise<UserUsage | null> => {
|
export type CreditOperation = "addEpisode" | "search" | "chatMessage";
|
||||||
return await prisma.userUsage.findUnique({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateUserCredits = async (
|
export class InsufficientCreditsError extends Error {
|
||||||
userUsage: UserUsage,
|
constructor(message: string) {
|
||||||
usedCredits: number,
|
super(message);
|
||||||
) => {
|
this.name = "InsufficientCreditsError";
|
||||||
return await prisma.userUsage.update({
|
}
|
||||||
where: {
|
}
|
||||||
id: userUsage.id,
|
|
||||||
},
|
/**
|
||||||
data: {
|
* Track usage analytics without enforcing limits (for self-hosted)
|
||||||
availableCredits: userUsage.availableCredits - usedCredits,
|
*/
|
||||||
usedCredits: userUsage.usedCredits + usedCredits,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -52,6 +52,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.4",
|
||||||
"@remix-run/express": "2.16.7",
|
"@remix-run/express": "2.16.7",
|
||||||
"@remix-run/node": "2.1.0",
|
"@remix-run/node": "2.1.0",
|
||||||
"@remix-run/react": "2.16.7",
|
"@remix-run/react": "2.16.7",
|
||||||
@ -131,6 +132,7 @@
|
|||||||
"remix-typedjson": "0.3.1",
|
"remix-typedjson": "0.3.1",
|
||||||
"remix-utils": "^7.7.0",
|
"remix-utils": "^7.7.0",
|
||||||
"sigma": "^3.0.2",
|
"sigma": "^3.0.2",
|
||||||
|
"stripe": "19.0.0",
|
||||||
"simple-oauth2": "^5.1.0",
|
"simple-oauth2": "^5.1.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwind-scrollbar-hide": "^2.0.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
|
updatedAt DateTime @updatedAt
|
||||||
deleted DateTime?
|
deleted DateTime?
|
||||||
|
|
||||||
|
// Current period tracking
|
||||||
availableCredits Int @default(0)
|
availableCredits Int @default(0)
|
||||||
usedCredits 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])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId String @unique
|
userId String @unique
|
||||||
@ -614,6 +625,69 @@ model WebhookDeliveryLog {
|
|||||||
createdAt DateTime @default(now())
|
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 {
|
model Workspace {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -643,6 +717,21 @@ model Workspace {
|
|||||||
RecallLog RecallLog[]
|
RecallLog RecallLog[]
|
||||||
Space Space[]
|
Space Space[]
|
||||||
MCPSession MCPSession[]
|
MCPSession MCPSession[]
|
||||||
|
Subscription Subscription?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PlanType {
|
||||||
|
FREE
|
||||||
|
PRO
|
||||||
|
MAX
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
ACTIVE
|
||||||
|
CANCELED
|
||||||
|
PAST_DUE
|
||||||
|
TRIALING
|
||||||
|
PAUSED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthenticationMethod {
|
enum AuthenticationMethod {
|
||||||
@ -656,6 +745,7 @@ enum IngestionStatus {
|
|||||||
COMPLETED
|
COMPLETED
|
||||||
FAILED
|
FAILED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
|
NO_CREDITS
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserType {
|
enum UserType {
|
||||||
|
|||||||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@ -367,6 +367,9 @@ importers:
|
|||||||
'@radix-ui/react-popover':
|
'@radix-ui/react-popover':
|
||||||
specifier: ^1.0.7
|
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)
|
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':
|
'@radix-ui/react-scroll-area':
|
||||||
specifier: ^1.0.5
|
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)
|
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:
|
simple-oauth2:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
|
stripe:
|
||||||
|
specifier: 19.0.0
|
||||||
|
version: 19.0.0(@types/node@20.19.7)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
@ -3743,6 +3749,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-roving-focus@1.1.0':
|
||||||
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
|
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -11322,6 +11341,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||||
engines: {node: '>=8'}
|
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:
|
strnum@1.1.2:
|
||||||
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
|
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
|
||||||
|
|
||||||
@ -16155,6 +16183,16 @@ snapshots:
|
|||||||
'@types/react': 18.2.69
|
'@types/react': 18.2.69
|
||||||
'@types/react-dom': 18.3.7(@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.0
|
'@radix-ui/primitive': 1.1.0
|
||||||
@ -25479,6 +25517,12 @@ snapshots:
|
|||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
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@1.1.2: {}
|
||||||
|
|
||||||
strnum@2.1.1: {}
|
strnum@2.1.1: {}
|
||||||
|
|||||||
12
turbo.json
12
turbo.json
@ -82,6 +82,16 @@
|
|||||||
"EMAIL_TRANSPORT",
|
"EMAIL_TRANSPORT",
|
||||||
"AWS_REGION",
|
"AWS_REGION",
|
||||||
"AWS_ACCESS_KEY_ID",
|
"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