diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index 3bc62b66..1f7d7498 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -48,6 +48,9 @@ jobs: AWS_SECRET_ACCESS_KEY AWS_REGION DECO_CHAT_DATA_BUCKET_NAME + STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET + CURRENCY_API_KEY ISSUER_JWT_SECRET env: SUPABASE_URL: ${{ secrets.SUPABASE_URL }} @@ -73,4 +76,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: ${{ secrets.AWS_REGION }} DECO_CHAT_DATA_BUCKET_NAME: ${{ secrets.DECO_CHAT_DATA_BUCKET_NAME }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + CURRENCY_API_KEY: ${{ secrets.CURRENCY_API_KEY }} ISSUER_JWT_SECRET: ${{ secrets.ISSUER_JWT_SECRET }} diff --git a/apps/api/main.ts b/apps/api/main.ts index 7491b84f..59e15469 100644 --- a/apps/api/main.ts +++ b/apps/api/main.ts @@ -10,7 +10,10 @@ import { default as app } from "./src/app.ts"; const instrumentedApp = getRuntimeKey() === "deno" ? app : instrument(app); // Domains we consider "self" -const SELF_DOMAINS: string[] = [Hosts.API, "localhost"]; +const SELF_DOMAINS: string[] = [ + Hosts.API, + `localhost:${Deno.env.get("PORT") || 3001}`, +]; // Patch fetch globally const originalFetch = globalThis.fetch; @@ -43,7 +46,7 @@ globalThis.fetch = async function patchedFetch( const context = contextStorage.getStore(); - if (SELF_DOMAINS.includes(url.hostname)) { + if (SELF_DOMAINS.includes(url.host)) { if (!context) { throw new Error("Missing context for internal self-invocation"); } diff --git a/apps/api/package.json b/apps/api/package.json index a3e8b87f..a4e2927f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "deno serve -A --port 3001 --unstable-hmr --unstable-kv --env-file=../web/.env main.ts", - "dev:wrangler": "wrangler dev --port 3001" + "dev:wrangler": "wrangler dev --port 3001", + "stripe:test": "stripe fixtures testing/stripe/fixtures/workspace-wallet-deposit.json" }, "dependencies": { "@deco/actors": "npm:@jsr/deco__actors@0.33.0", diff --git a/apps/api/src/api.ts b/apps/api/src/api.ts index 55de993d..26896974 100644 --- a/apps/api/src/api.ts +++ b/apps/api/src/api.ts @@ -21,7 +21,12 @@ import { withActorsMiddleware } from "./middlewares/actors.ts"; import { withActorsStubMiddleware } from "./middlewares/actorsStub.ts"; import { withContextMiddleware } from "./middlewares/context.ts"; import { setUserMiddleware } from "./middlewares/user.ts"; -import { AppContext, AppEnv, State } from "./utils/context.ts"; +import { + AppContext, + AppEnv, + State +} from "./utils/context.ts"; +import { handleStripeWebhook } from "./webhooks/stripe.ts"; export const app = new Hono(); export const honoCtxToAppCtx = (c: Context): AppContext => { @@ -215,6 +220,9 @@ Object.entries(loginRoutes).forEach(([route, honoApp]) => { app.route(route, honoApp); }); +// External webhooks +app.post("/webhooks/stripe", handleStripeWebhook); + // Health check endpoint app.get("/health", (c: Context) => c.json({ status: "ok" })); diff --git a/apps/api/src/webhooks/stripe.ts b/apps/api/src/webhooks/stripe.ts new file mode 100644 index 00000000..530e2f33 --- /dev/null +++ b/apps/api/src/webhooks/stripe.ts @@ -0,0 +1,64 @@ +import type { Context } from "hono"; +import { + createTransactionFromStripeEvent, + serializeError, + verifyAndParseStripeEvent, + WebhookEventIgnoredError, +} from "@deco/sdk/mcp"; +import { honoCtxToAppCtx } from "../api.ts"; +import { createWalletClient } from "@deco/sdk/mcp/wallet"; + +export const handleStripeWebhook = async (c: Context) => { + try { + const signature = c.req.header("stripe-signature"); + if (!signature) { + throw new Error("Stripe signature header not found"); + } + + const appContext = honoCtxToAppCtx(c); + + const payload = await c.req.text(); + const event = await verifyAndParseStripeEvent( + payload, + signature, + appContext, + ); + const transaction = await createTransactionFromStripeEvent( + appContext, + event, + ); + + if (!appContext.envVars.WALLET_API_KEY) { + throw new Error("WALLET_API_KEY is not set"); + } + + const wallet = createWalletClient( + appContext.envVars.WALLET_API_KEY, + appContext.walletBinding, + ); + + const response = await wallet["POST /transactions"]({}, { + body: transaction, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to create transaction: ${error}`); + } + + return c.json({ + message: "Transaction created", + }, 200); + } catch (error) { + if (error instanceof WebhookEventIgnoredError) { + return c.json({ + message: error.message, + }, 400); + } + + console.error("[Stripe Webhook] Error", serializeError(error)); + return c.json({ + message: "Internal server error", + }, 500); + } +}; diff --git a/apps/api/testing/stripe/fixtures/workspace-wallet-deposit.json b/apps/api/testing/stripe/fixtures/workspace-wallet-deposit.json new file mode 100644 index 00000000..cdb2afaf --- /dev/null +++ b/apps/api/testing/stripe/fixtures/workspace-wallet-deposit.json @@ -0,0 +1,47 @@ +{ + "_meta": { + "template_version": 0 + }, + "fixtures": [ + { + "name": "customer", + "path": "/v1/customers", + "method": "post", + "params": { + "name": "Viktor Marinho", + "email": "viktor@deco.cx", + "metadata": { + "product": "deco.chat", + "workspace": "/users/c40e818c-9a67-4dc6-ac3b-2bb6361663c2" + } + } + }, + { + "name": "payment_intent", + "path": "/v1/payment_intents", + "method": "post", + "params": { + "amount": 1418, + "confirm": "true", + "metadata": { + "test": "123" + }, + "customer": "${customer:id}", + "currency": "brl", + "description": "(created by Stripe CLI)", + "payment_method": "pm_card_visa", + "payment_method_types": ["card"], + "shipping": { + "name": "Jenny Rosen", + "address": { + "line1": "510 Townsend St", + "postal_code": "94103", + "city": "San Francisco", + "state": "CA", + "country": "US" + } + } + } + } + ] +} diff --git a/apps/web/package.json b/apps/web/package.json index bea78091..7280fb8c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "remark-gfm": "^4.0.1", "tiptap-markdown": "^0.8.10", "prismjs": "1.30.0", + "recharts": "^2.15.1", "react-syntax-highlighter": "15.6.1", "sonner": "^2.0.3", "zod": "3.24.3", diff --git a/apps/web/src/components/about/Hero.tsx b/apps/web/src/components/about/Hero.tsx index 8f528c03..87b68464 100644 --- a/apps/web/src/components/about/Hero.tsx +++ b/apps/web/src/components/about/Hero.tsx @@ -52,7 +52,6 @@ export function Hero({ }; const handleSecondaryButtonClick = () => { - console.log("secondary button clicked"); trackEvent("deco_chat_landing_learn_more_click", { buttonText: secondaryButtonText, buttonLink: secondaryButtonLink, diff --git a/apps/web/src/components/chat/ChatError.tsx b/apps/web/src/components/chat/ChatError.tsx index 449ae607..aecf4a16 100644 --- a/apps/web/src/components/chat/ChatError.tsx +++ b/apps/web/src/components/chat/ChatError.tsx @@ -10,12 +10,14 @@ import { TooltipTrigger, } from "@deco/ui/components/tooltip.tsx"; import { useState } from "react"; +import { useWorkspaceLink } from "../../hooks/useNavigateWorkspace.ts"; const WELL_KNOWN_ERROR_MESSAGES = { InsufficientFunds: "Insufficient funds", }; export function ChatError() { + const workspaceLink = useWorkspaceLink(); const { chat: { error }, retry, correlationIdRef } = useChatContext(); const insufficientFunds = error?.message.includes( WELL_KNOWN_ERROR_MESSAGES.InsufficientFunds, @@ -50,9 +52,9 @@ export function ChatError() { className="bg-background hover:bg-background/80 shadow-none border border-input py-3 px-4 h-10" asChild > - + - Add funds to your wallet + Add funds to the workspace wallet diff --git a/apps/web/src/components/common/InviteTeamMembersDialog.tsx b/apps/web/src/components/common/InviteTeamMembersDialog.tsx index a5eedb0f..a209fc99 100644 --- a/apps/web/src/components/common/InviteTeamMembersDialog.tsx +++ b/apps/web/src/components/common/InviteTeamMembersDialog.tsx @@ -71,7 +71,6 @@ export function InviteTeamMembersDialog({ const openDialog = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - console.log("Opening dialog manually"); setTimeout(() => { setIsOpen(true); }, 50); diff --git a/apps/web/src/components/settings/billing.tsx b/apps/web/src/components/settings/billing.tsx index 9497d644..6f558cc5 100644 --- a/apps/web/src/components/settings/billing.tsx +++ b/apps/web/src/components/settings/billing.tsx @@ -1,19 +1,627 @@ -import { SettingsMobileHeader } from "./SettingsMobileHeader.tsx"; +import { + type Agent, + type Member, + useAgents, + useTeamMembersBySlug, + useUsagePerAgent, + useUsagePerThread, + useWorkspaceWalletBalance, + WELL_KNOWN_AGENTS, +} from "@deco/sdk"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Card, CardContent } from "@deco/ui/components/card.tsx"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@deco/ui/components/chart.tsx"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@deco/ui/components/dialog.tsx"; import { Icon } from "@deco/ui/components/icon.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@deco/ui/components/select.tsx"; +import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { Suspense, useMemo, useState } from "react"; +import { Link, useParams } from "react-router"; +import { Label, Pie, PieChart } from "recharts"; +import { ErrorBoundary } from "../../ErrorBoundary.tsx"; +import { useUser } from "../../hooks/data/useUser.ts"; +import { useWorkspaceLink } from "../../hooks/useNavigateWorkspace.ts"; +import { useCurrentTeam } from "../sidebar/TeamSelector.tsx"; +import { DepositDialog } from "../wallet/DepositDialog.tsx"; +import { VoucherDialog } from "../wallet/VoucherDialog.tsx"; +import { SettingsMobileHeader } from "./SettingsMobileHeader.tsx"; + +interface UserAvatarProps { + member?: Member; + size?: "sm" | "md" | "lg"; +} + +function UserAvatar({ member, size = "md" }: UserAvatarProps) { + const sizeClasses = { + sm: "w-4 h-4", + md: "w-8 h-8", + lg: "w-12 h-12", + }; + + if (member?.profiles?.metadata?.avatar_url) { + return ( + {member.profiles.metadata?.full_name + ); + } + + return ( +
+ +
+ ); +} + +interface AgentAvatarProps { + agent?: Agent; + size?: "sm" | "md" | "lg"; +} + +function AgentAvatar({ agent, size = "md" }: AgentAvatarProps) { + const sizeClasses = { + sm: "w-10 h-10", + md: "w-12 h-12", + lg: "w-16 h-16", + }; + + if (agent?.id === WELL_KNOWN_AGENTS.teamAgent.id) { + return ( +
+ +
+ ); + } + + if (agent?.avatar) { + return ( + {agent.name + ); + } + + return ( +
+ +
+ ); +} + +function AccountBalance() { + const account = useWorkspaceWalletBalance(); + return

{account?.balance}

; +} + +function BalanceCard() { + const team = useCurrentTeam(); + const account = useWorkspaceWalletBalance(); + + return ( + + +
+ AI Usage Wallet +
+ +
+ {team.label} +
+
+ }> + Error loading balance

} + > + +
+
+
+
+ + +
+
+
+ ); +} + +function color(id: string) { + const colors = [ + "#FF6B6B", // coral red + "#4ECDC4", // turquoise + "#45B7D1", // sky blue + "#96CEB4", // sage green + "#FFEEAD", // cream + "#D4A5A5", // dusty rose + "#9B59B6", // purple + "#3498DB", // blue + "#E67E22", // orange + "#2ECC71", // emerald + "#F1C40F", // yellow + "#1ABC9C", // teal + "#E74C3C", // red + "#34495E", // navy + "#16A085", // green + "#D35400", // dark orange + "#8E44AD", // violet + "#2980B9", // dark blue + "#27AE60", // forest green + "#C0392B", // burgundy + ]; + + // Use the first part of the ID as a seed for consistent colors + const seed = id.split("-")[0]; + const hash = seed.split("").reduce( + (acc, char) => acc + char.charCodeAt(0), + 0, + ); + return colors[hash % colors.length]; +} + +function CreditsUsedPerAgentCard({ + agents: workspaceAgents, +}: { + agents: ReturnType; +}) { + const [range, setRange] = useState<"day" | "week" | "month">("month"); + const withWorkpaceLink = useWorkspaceLink(); + const usage = useUsagePerAgent({ range }); + + const total = usage.total; + const enrichedAgents = usage.items.map((_agent) => { + const agent = workspaceAgents.data?.find((a) => a.id === _agent.id); + return { + id: _agent.id, + total: _agent.total, + avatar: agent?.avatar, + label: agent?.name || _agent.label || _agent.id, + color: color(_agent.id), + }; + }).sort((a, b) => b.total - a.total); + + const chartConfig = Object.fromEntries( + enrichedAgents.map((agent) => [ + agent.id, + { + label: agent.label, + color: agent.color, + }, + ]), + ) satisfies ChartConfig; + + const agentsChartData = enrichedAgents.map((agent) => ({ + agentId: agent.id, + total: agent.total, + fill: agent.color, + })); + + return ( + +
+ Credits Used Per Agent +
+ +
+
+ +
+ + + } + /> + + + + +
+
    + {enrichedAgents.map((agent) => ( +
  • + +
    + + {agent.avatar && ( + {agent.label} + )} + + {agent.label} + +
    + +
  • + ))} +
+
+
+ ); +} + +CreditsUsedPerAgentCard.Fallback = () => ( + +
+ Credits Used Per Agent +
+ + + +
+); + +function CreditsUsedPerThread({ + agents: workspaceAgents, + teamMembers, +}: { + agents: ReturnType; + teamMembers: Member[]; +}) { + const withWorkpaceLink = useWorkspaceLink(); + const [range, setRange] = useState<"day" | "week" | "month">("week"); + const threads = useUsagePerThread({ range }); + + const enrichedThreads = threads.items.map((thread) => { + const agent = workspaceAgents.data?.find((a) => a.id === thread.agentId); + const member = teamMembers.find((m) => m.user_id === thread.generatedBy); + return { + agent, + member, + ...thread, + }; + }); + + return ( + +
+ Credits Used Per Thread + + +
+ +
+ {enrichedThreads.map((thread) => ( + + +
+
+ +
+ + {thread.agent?.name || "Unknown Agent"} + +
+ + + {thread.member?.profiles?.metadata?.full_name || + "Unknown User"} + +
+
+
+
+ + {thread.total} + +
+
+
+ +
+ ))} +
+
+ ); +} + +CreditsUsedPerThread.Fallback = () => ( + +
+ Credits Used Per Thread +
+ + {Array.from({ length: 10 }).map((_, index) => ( +
+ ))} + + +); + +interface ThreadDetailsProps { + thread: { + agent?: Agent; + member?: Member; + id: string; + total: string; + tokens?: { + totalTokens: number; + promptTokens: number; + completionTokens: number; + }; + }; + withWorkpaceLink: (path: string) => string; +} + +function ThreadDetails({ thread, withWorkpaceLink }: ThreadDetailsProps) { + return ( + + + Thread Details + +
+
+ +
+ + {thread.agent?.name || "Unknown Agent"} + + + {thread.total} credits used + +
+
+ +
+ +
+ + User + +
+ + + {thread.member?.profiles?.metadata?.full_name || "Unknown User"} + +
+
+ +
+
+ + Token Usage + +
+
+ + {thread.tokens?.totalTokens || 0} + + Total +
+
+ + {thread.tokens?.promptTokens || 0} + + Prompt +
+
+ + {thread.tokens?.completionTokens || 0} + + Completion +
+
+
+ + +
+ + ); +} + +function userToMember(user: ReturnType): Member { + return { + id: -1, + user_id: user.id, + profiles: { + email: user.email, + id: user.id, + is_anonymous: false, + metadata: user.metadata, + phone: user.phone, + }, + roles: [], + created_at: "", + lastActivity: "", + }; +} + +function useMembers() { + const { teamSlug } = useParams(); + const { data: _members } = useTeamMembersBySlug(teamSlug ?? null); + const user = useUser(); + + const members = useMemo(() => { + // if no members, it is the personal workspace of the user + // so we just format the current user to a Member + return _members?.length ? _members : [userToMember(user)]; + }, [_members]); + + return members; +} export default function BillingSettings() { + const _agents = useAgents(); + const agents = { + ..._agents, + data: _agents.data.concat([ + WELL_KNOWN_AGENTS.teamAgent, + WELL_KNOWN_AGENTS.setupAgent, + ]), + }; + const members = useMembers(); + return ( -
+
-
-
-
- -
-

Team Billing

-

- Coming soon -

+
+
+ + }> + + +
+
+ }> + +
diff --git a/apps/web/src/components/settings/page.tsx b/apps/web/src/components/settings/page.tsx index 69cca855..2ca3bb2a 100644 --- a/apps/web/src/components/settings/page.tsx +++ b/apps/web/src/components/settings/page.tsx @@ -19,6 +19,7 @@ const TABS: Record = { billing: { title: "Billing", Component: BillingSettings, + initialOpen: true, }, usage: { title: "Usage", diff --git a/apps/web/src/components/sidebar/footer.tsx b/apps/web/src/components/sidebar/footer.tsx index ab27e3d8..5e0033cf 100644 --- a/apps/web/src/components/sidebar/footer.tsx +++ b/apps/web/src/components/sidebar/footer.tsx @@ -37,7 +37,6 @@ import { Suspense, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { Link, useLocation } from "react-router"; import { ErrorBoundary } from "../../ErrorBoundary.tsx"; -import { trackEvent } from "../../hooks/analytics.ts"; import { useUser } from "../../hooks/data/useUser.ts"; import { useGitHubStars } from "../../hooks/useGitHubStars.ts"; import { useUserPreferences } from "../../hooks/useUserPreferences.ts"; @@ -155,12 +154,6 @@ function LoggedUser() { return url.href; }, [location.pathname]); - const handleWalletClick = () => { - trackEvent("sidebar_wallet_click", { - userId: user?.id, - }); - }; - const userAvatarURL = user?.metadata?.avatar_url ?? undefined; const userName = user?.metadata?.full_name || user?.email; const formattedStars = stars @@ -184,17 +177,6 @@ function LoggedUser() { align="start" className="md:w-[200px] text-slate-700" > - - - - Wallet - - - + createWalletCheckoutSession({ + workspace, + amountUSDCents: amountInCents, + successUrl: `${location.origin}/settings/billing?success=true`, + cancelUrl: `${location.origin}/settings/billing?success=false`, + }), + }); + + const user = useUser(); + const [creditAmount, setCreditAmount] = useState(""); + const [amountError, setAmountError] = useState(""); + + const handleAmountChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const digits = parseCurrency(value); + setCreditAmount(digits); + setAmountError(""); + }; + + function validateAmount() { + const amount = parseInt(creditAmount); + if (isNaN(amount) || amount < MINIMUM_AMOUNT) { + setAmountError( + `Minimum deposit amount is ${ + formatCurrency(MINIMUM_AMOUNT.toString()) + }`, + ); + return false; + } + return true; + } + + return ( + + + + + + + Add credits to your wallet + +
+
+ + {amountError && ( +

{amountError}

+ )} +
+ {createCheckoutSession.error + ? ( +

+ We could not create a checkout session for you now.
Please + try again later. +

+ ) + : null} + +
+
+
+ ); +} diff --git a/apps/web/src/components/wallet/VoucherDialog.tsx b/apps/web/src/components/wallet/VoucherDialog.tsx new file mode 100644 index 00000000..b8b2247d --- /dev/null +++ b/apps/web/src/components/wallet/VoucherDialog.tsx @@ -0,0 +1,66 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@deco/ui/components/dialog.tsx"; +import { Icon } from "@deco/ui/components/icon.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { redeemWalletVoucher, useSDK } from "@deco/sdk"; +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +export function VoucherDialog() { + const [voucher, setVoucher] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const { workspace } = useSDK(); + const queryClient = useQueryClient(); + + const { mutate: redeemVoucher, isPending } = useMutation({ + mutationFn: () => redeemWalletVoucher({ workspace, voucher }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["wallet"] }); + setIsOpen(false); + setVoucher(""); + }, + }); + + return ( + + + + + + + Redeem voucher + +
+
+ + setVoucher(e.target.value)} + placeholder="Enter your voucher code" + className="w-full" + /> +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/wallet/index.tsx b/apps/web/src/components/wallet/index.tsx deleted file mode 100644 index 26d3875e..00000000 --- a/apps/web/src/components/wallet/index.tsx +++ /dev/null @@ -1,464 +0,0 @@ -import { - createWalletCheckoutSession, - getWalletAccount, - getWalletStatements, -} from "@deco/sdk"; -import { - Alert, - AlertDescription, - AlertTitle, -} from "@deco/ui/components/alert.tsx"; -import { Button } from "@deco/ui/components/button.tsx"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@deco/ui/components/dialog.tsx"; -import { Icon } from "@deco/ui/components/icon.tsx"; -import { Input } from "@deco/ui/components/input.tsx"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@deco/ui/components/select.tsx"; -import { Skeleton } from "@deco/ui/components/skeleton.tsx"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@deco/ui/components/tooltip.tsx"; -import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { trackEvent } from "../../hooks/analytics.ts"; -import { useUser } from "../../hooks/data/useUser.ts"; -import { - useIncomingUrlAlert, - WalletUrlAlert, -} from "../../hooks/useIncomingUrlAlert.ts"; -import { Avatar } from "../common/Avatar.tsx"; - -const MINIMUM_AMOUNT = 200; // $2.00 in cents - -function formatCurrency(value: string) { - // Remove all non-digit characters - const digits = value.replace(/\D/g, ""); - - // Convert to number and format with 2 decimal places - const amount = parseFloat(digits) / 100; - - // Format as currency - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - }).format(amount); -} - -function parseCurrency(value: string) { - // Remove all non-digit characters - return value.replace(/\D/g, ""); -} - -function AccountValue() { - const { data, isLoading, error } = useQuery({ - queryKey: ["wallet"], - queryFn: () => getWalletAccount(), - }); - - if (isLoading) return ; - if (error) return

Error loading wallet

; - - return

{data?.balance}

; -} - -function Activity() { - const [cursor, setCursor] = useState(""); - const { data: statements, isLoading, error, isFetching } = useQuery({ - queryKey: ["wallet-statements", cursor], - queryFn: () => getWalletStatements(cursor), - placeholderData: keepPreviousData, - }); - - if (isLoading) { - return ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ); - } - - if (error) return

Error loading statements

; - if (!statements?.items.length) { - return

No activity yet

; - } - - return ( -
-

Activity

-
- {statements.items.map((statement) => ( - - -
-
-
- {statement.icon - ? - : ( - - )} -
-
-

- {statement.title} -

- {statement.description && ( -

- {statement.description} -

- )} -

- {new Date(statement.timestamp).toLocaleDateString( - undefined, - { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }, - )} -

-
-
-

- {statement.amountExact} -

-
-
- - - Transaction Details - -
-
-
-
- {statement.icon - ? - : ( - - )} -
-
-

- {statement.title} -

-

- {statement.amountExact} -

-
-
- -
-

- {new Date(statement.timestamp).toLocaleDateString( - undefined, - { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }, - )} -

- {statement.description && ( -

- {statement.description} -

- )} -
- - {statement.metadata && ( -
-

- Details -

- - - {Object.entries(statement.metadata).map(( - [key, value], - ) => ( - - - - - ))} - -
{key} - - - - {value as string} - - - - {value as string} - - -
-
- )} -
-
-
-
- ))} - {isFetching ?
Loading more...
: null} - {statements?.nextCursor && ( - - )} -
-
- ); -} - -function capitalize(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -function WalletAlert({ - alert, - remove, -}: { - alert: WalletUrlAlert; - remove: () => void; -}) { - if (alert.type === "success") { - trackEvent("wallet_credit_success", { - message: alert.message, - }); - } - - return ( - - {capitalize(alert.type)} - {alert.message} - - - ); -} - -function Wallet() { - const queryStringAlert = useIncomingUrlAlert(); - const createCheckoutSession = useMutation({ - mutationFn: (amountInCents: number) => - createWalletCheckoutSession(amountInCents), - }); - const user = useUser(); - const [creditAmount, setCreditAmount] = useState(""); - const [amountError, setAmountError] = useState(""); - - useEffect(() => { - if (queryStringAlert.alert?.type === "success") { - trackEvent("wallet_credit_success", { - message: queryStringAlert.alert.message, - }); - } - }, [queryStringAlert.alert]); - - const userAvatarURL = user?.metadata?.avatar_url ?? undefined; - - const handleAmountChange = (e: React.ChangeEvent) => { - const value = e.target.value; - const digits = parseCurrency(value); - setCreditAmount(digits); - setAmountError(""); - }; - - const validateAmount = () => { - const amount = parseInt(creditAmount); - if (isNaN(amount) || amount < MINIMUM_AMOUNT) { - setAmountError( - `Minimum deposit amount is ${ - formatCurrency(MINIMUM_AMOUNT.toString()) - }`, - ); - return false; - } - return true; - }; - - return ( -
- {queryStringAlert.alert - ? ( - - ) - : null} -
-
- -
- -
-
-
-
- - Balance -
-
- - - - - - - Add credits to your wallet - -
-
- - {amountError && ( -

{amountError}

- )} -
- {createCheckoutSession.error - ? ( -

- We could not create a checkout session for you now. -
- Please try again later. -

- ) - : ( - - )} -
-
-
-
- -
-
-
- ); -} - -export default Wallet; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9bab2cc8..701572af 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -88,10 +88,6 @@ const PublicChats = lazy( () => wrapWithUILoadingFallback(import("./components/agent/chats.tsx")), ); -const Wallet = lazy( - () => wrapWithUILoadingFallback(import("./components/wallet/index.tsx")), -); - const AuditList = lazy( () => wrapWithUILoadingFallback(import("./components/audit/list.tsx")), ); @@ -261,10 +257,6 @@ function Router() { } /> - } - /> } diff --git a/packages/ai/src/agent.ts b/packages/ai/src/agent.ts index c97dd6e1..874e264f 100644 --- a/packages/ai/src/agent.ts +++ b/packages/ai/src/agent.ts @@ -39,7 +39,7 @@ import { createServerTimings, type ServerTimingsBuilder, } from "@deco/sdk/timings"; -import { createWalletClient } from "@deco/sdk/wallet"; +import { createWalletClient } from "../../sdk/src/mcp/wallet/index.ts"; import type { StorageThreadType } from "@mastra/core"; import type { ToolsetsInput, ToolsInput } from "@mastra/core/agent"; import { Agent } from "@mastra/core/agent"; @@ -543,6 +543,14 @@ export class AIAgent extends BaseActor implements IIAgent { await this.initAgent(config); } + // todo(@camudo): change this to a nice algorithm someday + private inferBestModel(model: string) { + if (model === "auto") { + return "openai:gpt-4.1-mini"; + } + return model; + } + private createLLM( { model, bypassGateway, bypassOpenRouter }: { model: string; @@ -550,11 +558,7 @@ export class AIAgent extends BaseActor implements IIAgent { bypassOpenRouter?: boolean; }, ): { llm: LanguageModelV1; tokenLimit: number } { - // todo(@camudo): change this to a nice algorithm someday - if (model === "auto") { - model = "openai:gpt-4.1-mini"; - } - + model = this.inferBestModel(model); const [provider, ...rest] = model.split(":"); const providerModel = rest.join(":"); const accountId = this.env?.ACCOUNT_ID ?? DEFAULT_ACCOUNT_ID; @@ -834,17 +838,13 @@ export class AIAgent extends BaseActor implements IIAgent { const agent = this.withAgentOverrides(options); agentOverridesTiming.end(); - // if no wallet was initialized, let the stream proceed. - // we can change this later to be more restrictive. const wallet = this.wallet; - const userId = this.metadata?.user?.id; - if (userId) { - const walletTiming = timings.start("init-wallet"); - const hasBalance = await wallet.canProceed(userId); - walletTiming.end(); - if (!hasBalance) { - throw new Error("Insufficient funds"); - } + const walletTiming = timings.start("init-wallet"); + const hasBalance = await wallet.canProceed(); + walletTiming.end(); + + if (!hasBalance) { + throw new Error("Insufficient funds"); } const ttfbSpan = tracer.startSpan("stream-ttfb", { @@ -912,15 +912,16 @@ export class AIAgent extends BaseActor implements IIAgent { // TODO(@mcandeia): add error tracking with posthog }, onFinish: (result) => { - if (userId) { - wallet.computeLLMUsage({ - userId, - usage: result.usage, - threadId: this.thread.threadId, - model: this._configuration?.model ?? DEFAULT_MODEL, - agentName: this._configuration?.name ?? ANONYMOUS_NAME, - }); - } + const model = this.inferBestModel( + this._configuration?.model ?? DEFAULT_MODEL, + ); + wallet.computeLLMUsage({ + userId: this.metadata?.user?.id, + usage: result.usage, + threadId: this.thread.threadId, + model, + agentName: this._configuration?.name ?? ANONYMOUS_NAME, + }); }, }); streamTiming.end(); diff --git a/packages/ai/src/wallet/index.ts b/packages/ai/src/wallet/index.ts index ba2cbb7c..6face583 100644 --- a/packages/ai/src/wallet/index.ts +++ b/packages/ai/src/wallet/index.ts @@ -5,7 +5,7 @@ import { type WalletAPI, WellKnownTransactions, WellKnownWallets, -} from "@deco/sdk/wallet"; +} from "@deco/sdk/mcp/wallet"; import type { LanguageModelUsage } from "ai"; import { WebCache } from "@deco/sdk/cache"; @@ -17,7 +17,7 @@ export interface AgentWalletConfig { } export interface ComputeAgentUsageOpts { - userId: string; + userId?: string; usage: LanguageModelUsage; threadId: string; model: string; @@ -37,23 +37,23 @@ export class AgentWallet { private rewardPromise: Map> = new Map(); constructor(private config: AgentWalletConfig) {} - async updateBalanceCache(userId: string) { - const hasBalance = await this.hasBalance(userId); - this.hasBalanceCache.set(userId, hasBalance); + async updateBalanceCache() { + const hasBalance = await this.hasBalance(); + this.hasBalanceCache.set(this.config.workspace, hasBalance); return hasBalance; } - async canProceed(userId: string) { - const hasBalance = await this.hasBalanceCache.get(userId); + async canProceed() { + const hasBalance = await this.hasBalanceCache.get(this.config.workspace); if (typeof hasBalance === "boolean") { if (!hasBalance) { - return this.updateBalanceCache(userId); // lazy update + return this.updateBalanceCache(); // lazy update } return hasBalance; } // TODO (@mcandeia) this can cause users using their wallet without credit for few times. - this.updateBalanceCache(userId); // update in background + this.updateBalanceCache(); // update in background return true; } @@ -61,16 +61,20 @@ export class AgentWallet { return this.config.wallet; } - async hasBalance(userId: string) { - await this.rewardUserIfNeeded(userId); + async hasBalance() { + await this.rewardFreeCreditsIfNeeded(); const walletId = WellKnownWallets.build( - ...WellKnownWallets.user.genCredits(userId), + ...WellKnownWallets.workspace.genCredits(this.config.workspace), ); - const response = await this.config.wallet["GET /accounts/:id"]({ - id: walletId, + const response = await this.client["GET /accounts/:id"]({ + id: encodeURIComponent(walletId), }); + if (response.status === 404) { + return false; + } + if (!response.ok) { console.error("Failed to check balance", response); return true; @@ -101,11 +105,11 @@ export class AgentWallet { }; const vendor = { type: "vendor", - id: userId, // TODO: this should be the agent's vendor id, once we have a way to sell agents + id: this.config.workspace, }; const generatedBy = { type: "user", - id: userId, + id: userId || "unknown", }; const operation = { @@ -124,7 +128,7 @@ export class AgentWallet { }, }; - const response = await this.config.wallet["POST /transactions"]({}, { + const response = await this.client["POST /transactions"]({}, { body: operation, }); @@ -132,31 +136,33 @@ export class AgentWallet { // TODO(@mcandeia): add error tracking with posthog } - this.updateBalanceCache(userId); + this.updateBalanceCache(); } - ensureCreditRewards(userId: string): Promise { + ensureCreditRewards(): Promise { if (this.checkedUserCreditReward) { return Promise.resolve(); } - if (this.rewardPromise.has(userId)) { - return this.rewardPromise.get(userId) ?? Promise.resolve(); + if (this.rewardPromise.has(this.config.workspace)) { + return this.rewardPromise.get(this.config.workspace) ?? Promise.resolve(); } const promise = (async () => { const rewards = [ { - type: "GenCreditsReward" as const, + type: "WorkspaceGenCreditReward" as const, amount: "2_000000", - userId, - transactionId: WellKnownTransactions.freeTwoDollars(userId), + workspace: this.config.workspace, + transactionId: WellKnownTransactions.freeTwoDollars( + encodeURIComponent(this.config.workspace), + ), }, ]; await Promise.all( rewards.map(async (operation) => { - const response = await this.config.wallet["PUT /transactions/:id"]( + const response = await this.client["PUT /transactions/:id"]( { id: operation.transactionId }, { body: operation }, ); @@ -171,24 +177,26 @@ export class AgentWallet { ); this.checkedUserCreditReward = true; - this.rewardPromise.delete(userId); + this.rewardPromise.delete(this.config.workspace); })(); - this.rewardPromise.set(userId, promise); + this.rewardPromise.set(this.config.workspace, promise); return promise; } - async rewardUserIfNeeded(userId: string) { - const wasRewarded = await this.userCreditsRegardsCache.get(userId); + async rewardFreeCreditsIfNeeded() { + const wasRewarded = await this.userCreditsRegardsCache.get( + this.config.workspace, + ); if (wasRewarded) { // User was already rewarded, skip return; } - await this.ensureCreditRewards(userId); + await this.ensureCreditRewards(); // Mark as rewarded - await this.userCreditsRegardsCache.set(userId, true); + await this.userCreditsRegardsCache.set(this.config.workspace, true); } } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 84b96d08..02ba20c8 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -22,6 +22,7 @@ "jwt-decode": "^4.0.0", "lru-cache": "^11.1.0", "react": "^19.1.0", + "stripe": "18.0.0", "zod": "3.24.3", "@aws-sdk/client-s3": "^3.808.0", "uuid": "^11.1.0", @@ -56,9 +57,9 @@ "./auth": "./src/auth/index.ts", "./timings": "./src/timings.ts", "./http": "./src/http.ts", - "./wallet": "./src/wallets/index.ts", "./storage": "./src/storage/index.ts", "./mcp": "./src/mcp/index.ts", + "./mcp/wallet": "./src/mcp/wallet/index.ts", "./fetch": "./src/fetch.ts", "./hooks": "./src/hooks/index.ts", "./hooks/profile": "./src/hooks/profile.ts" diff --git a/packages/sdk/src/crud/wallet.ts b/packages/sdk/src/crud/wallet.ts index 126b06ea..d9822890 100644 --- a/packages/sdk/src/crud/wallet.ts +++ b/packages/sdk/src/crud/wallet.ts @@ -1,40 +1,68 @@ -// deno-lint-ignore-file no-explicit-any -import { fetchAPI } from "../fetcher.ts"; - -export const getWalletAccount = async () => { - const response = await fetchAPI({ - segments: ["wallet", "account"], - }); - return response.json(); -}; - -interface WalletStatement { - id: string; - timestamp: string; - title: string; - amount: string; - amountExact: string; - description?: string; - type: "credit" | "debit"; - icon?: string; - metadata?: Record; -} - -export const getWalletStatements = async (cursor?: string) => { - const response = await fetchAPI({ - path: `/wallet/statements${cursor ? `?cursor=${cursor}` : ""}`, - }); - return response.json() as Promise<{ - items: WalletStatement[]; - nextCursor: string; - }>; -}; - -export const createWalletCheckoutSession = async (amountInCents: number) => { - const response = await fetchAPI({ - segments: ["wallet", "checkout"], - method: "POST", - body: JSON.stringify({ amountInCents }), - }); - return response.json() as Promise<{ checkoutUrl: string }>; -}; +import { MCPClient } from "../fetcher.ts"; + +export const getWalletAccount = (workspace: string) => + MCPClient.forWorkspace(workspace) + .GET_WALLET_ACCOUNT({}); + +export const getThreadsUsage = ( + workspace: string, + range: "day" | "week" | "month", +) => + MCPClient.forWorkspace(workspace) + .GET_THREADS_USAGE({ + range, + }); + +export const getAgentsUsage = ( + workspace: string, + range: "day" | "week" | "month", +) => + MCPClient.forWorkspace(workspace) + .GET_AGENTS_USAGE({ + range, + }); + +export const createWalletCheckoutSession = ({ + workspace, + amountUSDCents, + successUrl, + cancelUrl, +}: { + workspace: string; + amountUSDCents: number; + successUrl: string; + cancelUrl: string; +}) => + MCPClient.forWorkspace(workspace) + .CREATE_CHECKOUT_SESSION({ + amountUSDCents, + successUrl, + cancelUrl, + }); + + +export const redeemWalletVoucher = ({ + workspace, + voucher, +}: { + workspace: string; + voucher: string; +}) => + MCPClient.forWorkspace(workspace) + .REDEEM_VOUCHER({ + voucher, + }); + +export const createWalletVoucher = ({ + workspace, + amount, +}: { + workspace: string; + amount: number; +}) => + MCPClient.forWorkspace(workspace) + .CREATE_VOUCHER({ + amount, + }); + + diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index b27b1108..1b0d9749 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -46,6 +46,13 @@ export class InternalServerError extends HttpError { } } +export class WebhookEventIgnoredError extends HttpError { + override code = 400; + constructor(message: string = "Event ignored", traceId?: string) { + super(message, traceId); + } +} + export const getErrorByStatusCode = ( statusCode: number, message?: string, diff --git a/packages/sdk/src/hooks/api.ts b/packages/sdk/src/hooks/api.ts index 4fc4bec9..93f6156a 100644 --- a/packages/sdk/src/hooks/api.ts +++ b/packages/sdk/src/hooks/api.ts @@ -59,4 +59,15 @@ export const KEYS = { threadId: string, ) => ["thread-tools", workspace, threadId], PROFILE: () => ["profile"], + WALLET: ( + workspace: Workspace, + ) => ["wallet", workspace], + WALLET_USAGE_AGENTS: ( + workspace: Workspace, + range: "day" | "week" | "month", + ) => ["wallet-usage-agents", workspace, range], + WALLET_USAGE_THREADS: ( + workspace: Workspace, + range: "day" | "week" | "month", + ) => ["wallet-usage-threads", workspace, range], }; diff --git a/packages/sdk/src/hooks/index.ts b/packages/sdk/src/hooks/index.ts index 2e6dd2db..b98a0bc8 100644 --- a/packages/sdk/src/hooks/index.ts +++ b/packages/sdk/src/hooks/index.ts @@ -10,3 +10,4 @@ export * from "./trigger.ts"; export * from "./agent.ts"; export * from "./audit.ts"; export * from "./fs.ts"; +export * from "./wallet.ts"; diff --git a/packages/sdk/src/hooks/members.ts b/packages/sdk/src/hooks/members.ts index 92896de1..869adf28 100644 --- a/packages/sdk/src/hooks/members.ts +++ b/packages/sdk/src/hooks/members.ts @@ -3,7 +3,7 @@ import { useQueryClient, useSuspenseQuery, } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { acceptInvite, getMyInvites, @@ -17,6 +17,7 @@ import { type Role as _Role, } from "../crud/members.ts"; import { KEYS } from "./api.ts"; +import { useTeams } from "./teams.ts"; /** * Hook to fetch team members @@ -35,6 +36,19 @@ export const useTeamMembers = ( }); }; +/** + * Hook to fetch team members for the current team + * @param currentTeamSlug - The slug of the current team + */ +export const useTeamMembersBySlug = (currentTeamSlug: string | null) => { + const { data: teams } = useTeams(); + const teamId = useMemo( + () => teams?.find((t) => t.slug === currentTeamSlug)?.id ?? null, + [teams, currentTeamSlug], + ); + return useTeamMembers(teamId); +}; + /** * Hook to fetch team roles * @param teamId - The ID of the team to fetch roles for diff --git a/packages/sdk/src/hooks/wallet.ts b/packages/sdk/src/hooks/wallet.ts new file mode 100644 index 00000000..b53758a8 --- /dev/null +++ b/packages/sdk/src/hooks/wallet.ts @@ -0,0 +1,53 @@ +import { + getAgentsUsage, + getThreadsUsage, + getWalletAccount, +} from "../crud/wallet.ts"; +import { KEYS } from "./api.ts"; +import { useSDK } from "./store.tsx"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; + +export function useWorkspaceWalletBalance() { + const { workspace } = useSDK(); + const queryClient = useQueryClient(); + const { data: account, isRefetching } = useSuspenseQuery({ + queryKey: KEYS.WALLET(workspace), + queryFn: () => getWalletAccount(workspace), + }); + + return { + ...account, + refetch: () => + queryClient.invalidateQueries({ queryKey: KEYS.WALLET(workspace) }), + isRefetching, + }; +} + +export function useUsagePerAgent({ + range, +}: { + range: "day" | "week" | "month"; +}) { + const { workspace } = useSDK(); + + const { data: usage } = useSuspenseQuery({ + queryKey: KEYS.WALLET_USAGE_AGENTS(workspace, range), + queryFn: () => getAgentsUsage(workspace, range), + }); + + return usage; +} + +export function useUsagePerThread({ + range, +}: { + range: "day" | "week" | "month"; +}) { + const { workspace } = useSDK(); + const { data: usage } = useSuspenseQuery({ + queryKey: KEYS.WALLET_USAGE_THREADS(workspace, range), + queryFn: () => getThreadsUsage(workspace, range), + }); + + return usage; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 06d32b7e..f26bdd8a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -19,6 +19,7 @@ export * from "./hooks/trigger.ts"; export * from "./hooks/members.ts"; export * from "./hooks/audit.ts"; export * from "./hooks/teams.ts"; +export * from "./hooks/wallet.ts"; export * from "./models/agent.ts"; export * from "./models/mcp.ts"; diff --git a/packages/sdk/src/mcp/context.ts b/packages/sdk/src/mcp/context.ts index b14bb142..64e49bbe 100644 --- a/packages/sdk/src/mcp/context.ts +++ b/packages/sdk/src/mcp/context.ts @@ -29,6 +29,7 @@ export interface Vars { authorization: AuthorizationClient; isLocal?: boolean; cf: Cloudflare; + walletBinding?: { fetch: typeof fetch }; immutableRes?: boolean; stub: < Constructor extends @@ -40,7 +41,6 @@ export interface Vars { } export type EnvVars = z.infer; - export type AppContext = Vars & { envVars: EnvVars; }; @@ -94,6 +94,15 @@ const envSchema = z.object({ OPENROUTER_API_KEY: z.string().readonly(), TURSO_ADMIN_TOKEN: z.any().optional().readonly(), OPENAI_API_KEY: z.any().optional().readonly(), + + /** + * Only needed for locally testing wallet features. + */ + WALLET_API_KEY: z.string().nullish(), + STRIPE_SECRET_KEY: z.string().nullish(), + STRIPE_WEBHOOK_SECRET: z.string().nullish(), + CURRENCY_API_KEY: z.string().nullish(), + TESTING_CUSTOMER_ID: z.string().nullish(), }); export const getEnv = (ctx: AppContext): EnvVars => diff --git a/packages/sdk/src/mcp/index.ts b/packages/sdk/src/mcp/index.ts index fe69e390..9afb1b8a 100644 --- a/packages/sdk/src/mcp/index.ts +++ b/packages/sdk/src/mcp/index.ts @@ -1,6 +1,7 @@ export * from "../errors.ts"; export * from "./assertions.ts"; export * from "./context.ts"; +export * from "./wallet/stripe/webhook.ts"; import * as agentsAPI from "./agents/api.ts"; import { AppContext, State } from "./context.ts"; import * as fsAPI from "./fs/api.ts"; @@ -13,6 +14,7 @@ import { CreateStubHandlerOptions, MCPClientStub } from "./stub.ts"; import * as teamsAPI from "./teams/api.ts"; import * as threadsAPI from "./threads/api.ts"; import * as triggersAPI from "./triggers/api.ts"; +import * as walletAPI from "./wallet/api.ts"; // Register tools for each API handler export const GLOBAL_TOOLS = [ @@ -66,6 +68,12 @@ export const WORKSPACE_TOOLS = [ triggersAPI.createWebhookTrigger, triggersAPI.deleteTrigger, triggersAPI.getWebhookTriggerUrl, + walletAPI.getWalletAccount, + walletAPI.getThreadsUsage, + walletAPI.getAgentsUsage, + walletAPI.createCheckoutSession, + walletAPI.redeemWalletVoucher, + walletAPI.createWalletVoucher, triggersAPI.activateTrigger, triggersAPI.deactivateTrigger, knowledgeAPI.createBase, @@ -142,7 +150,6 @@ export function createMCPToolsStub( return State.run( options?.context ?? State.getStore(), async (args) => { - // @ts-expect-error this should be fine const result = await tool.handler(args); if (result.isError) { diff --git a/packages/sdk/src/mcp/wallet/api.ts b/packages/sdk/src/mcp/wallet/api.ts new file mode 100644 index 00000000..e32379ea --- /dev/null +++ b/packages/sdk/src/mcp/wallet/api.ts @@ -0,0 +1,303 @@ +import { z } from "zod"; +import { AppContext, createTool } from "../context.ts"; +import { + createWalletClient, + MicroDollar, + WalletAPI, + WellKnownWallets, +} from "./index.ts"; +import { ClientOf } from "@deco/sdk/http"; +import { + assertHasWorkspace, + canAccessWorkspaceResource, +} from "../assertions.ts"; +import { createCheckoutSession as createStripeCheckoutSession } from "./stripe/checkout.ts"; +import { InternalServerError, UserInputError } from "../../errors.ts"; + +const getWalletClient = (c: AppContext) => { + if (!c.envVars.WALLET_API_KEY) { + throw new InternalServerError("WALLET_API_KEY is not set"); + } + return createWalletClient(c.envVars.WALLET_API_KEY, c.walletBinding); +}; + +const Account = { + fetch: async (wallet: ClientOf, id: string) => { + const accountResponse = await wallet["GET /accounts/:id"]({ + id: encodeURIComponent(id), + }); + + if (accountResponse.status === 404) { + return null; + } + + if (!accountResponse.ok) { + throw new Error("Failed to fetch account"); + } + + return accountResponse.json(); + }, + format: (account: WalletAPI["GET /accounts/:id"]["response"]) => { + return { + balance: MicroDollar.fromMicrodollarString(account.balance).display(), + balanceExact: MicroDollar.fromMicrodollarString(account.balance).display({ + showAllDecimals: true, + }), + }; + }, +}; + +const isNotNull = (value: T | null): value is T => Boolean(value); + +const ThreadsUsage = { + fetch: async ( + wallet: ClientOf, + workspace: string, + range: "day" | "week" | "month", + ) => { + const usageResponse = await wallet["GET /usage/threads"]({ + workspace: encodeURIComponent(workspace), + range, + }); + + if (!usageResponse.ok) { + throw new Error("Failed to fetch usage"); + } + + return usageResponse.json(); + }, + format: ( + usage: WalletAPI["GET /usage/threads"]["response"], + ) => { + return { + items: usage.items.map((thread) => ({ + ...thread, + total: MicroDollar.fromMicrodollarString(thread.total).display({ + showAllDecimals: true, + }), + })).filter(isNotNull), + }; + }, +}; + +const AgentsUsage = { + fetch: async ( + wallet: ClientOf, + workspace: string, + range: "day" | "week" | "month", + ) => { + const usageResponse = await wallet["GET /usage/agents"]({ + workspace, + range, + }); + + if (!usageResponse.ok) { + throw new Error("Failed to fetch usage"); + } + + return usageResponse.json(); + }, + format: (usage: WalletAPI["GET /usage/agents"]["response"]) => { + return { + total: MicroDollar.fromMicrodollarString(usage.total).display(), + items: usage.items.map((item) => ({ + id: item.id, + label: item.label, + total: MicroDollar.fromMicrodollarString(item.total).toDollars(), + })), + }; + }, +}; + +export const getWalletAccount = createTool({ + name: "GET_WALLET_ACCOUNT", + description: "Get the wallet account for the current tenant", + inputSchema: z.object({}), + canAccess: canAccessWorkspaceResource, + handler: async (_, c) => { + assertHasWorkspace(c); + + const wallet = getWalletClient(c); + + const workspaceWalletId = WellKnownWallets.build( + ...WellKnownWallets.workspace.genCredits(c.workspace.value), + ); + const data = await Account.fetch(wallet, workspaceWalletId); + + if (!data) { + return { + balance: MicroDollar.ZERO.display(), + balanceExact: MicroDollar.ZERO.display({ + showAllDecimals: true, + }), + }; + } + + return Account.format(data); + }, +}); + +export const getThreadsUsage = createTool({ + name: "GET_THREADS_USAGE", + description: "Get the threads usage for the current tenant's wallet", + inputSchema: z.object({ + range: z.enum(["day", "week", "month"]), + }), + canAccess: canAccessWorkspaceResource, + handler: async ({ range }, ctx) => { + assertHasWorkspace(ctx); + + const wallet = getWalletClient(ctx); + + const usage = await ThreadsUsage.fetch( + wallet, + ctx.workspace.value, + range, + ); + return ThreadsUsage.format(usage); + }, +}); + +export const getAgentsUsage = createTool({ + name: "GET_AGENTS_USAGE", + description: "Get the agents usage for the current tenant's wallet", + inputSchema: z.object({ + range: z.enum(["day", "week", "month"]), + }), + canAccess: canAccessWorkspaceResource, + handler: async ({ range }, ctx) => { + assertHasWorkspace(ctx); + + const wallet = getWalletClient(ctx); + + const usage = await AgentsUsage.fetch( + wallet, + ctx.workspace.value, + range, + ); + return AgentsUsage.format(usage); + }, +}); + +export const createCheckoutSession = createTool({ + name: "CREATE_CHECKOUT_SESSION", + description: "Create a checkout session for the current tenant's wallet", + inputSchema: z.object({ + amountUSDCents: z.number(), + successUrl: z.string(), + cancelUrl: z.string(), + }), + canAccess: canAccessWorkspaceResource, + handler: async ({ amountUSDCents, successUrl, cancelUrl }, ctx) => { + assertHasWorkspace(ctx); + + const session = await createStripeCheckoutSession({ + successUrl, + cancelUrl, + product: { + id: "WorkspaceWalletDeposit", + amountUSD: amountUSDCents, + }, + ctx, + metadata: { + created_by_user_id: ctx.user.id as string, + created_by_user_email: (ctx.user.email || "") as string, + }, + }); + + return { + url: session.url, + }; + }, +}); + +export const createWalletVoucher = createTool({ + name: "CREATE_VOUCHER", + description: "Create a voucher with money from the current tenant's wallet", + inputSchema: z.object({ + amount: z.number().describe( + "The amount of money to add to the voucher. Specified in USD dollars.", + ), + }), + canAccess: canAccessWorkspaceResource, + handler: async ({ amount }, ctx) => { + assertHasWorkspace(ctx); + + const wallet = getWalletClient(ctx); + const id = crypto.randomUUID(); + const amountMicroDollars = MicroDollar.fromDollars(amount); + const claimableId = `${id}-${amountMicroDollars.toMicrodollarString()}`; + + if (amountMicroDollars.isZero() || amountMicroDollars.isNegative()) { + throw new UserInputError("Amount must be positive"); + } + + const operation = { + type: "WorkspaceCreateVoucher" as const, + amount: amountMicroDollars.toMicrodollarString(), + voucherId: id, + workspace: ctx.workspace.value, + } as const; + + const response = await wallet["POST /transactions"]({}, { + body: operation, + }); + + if (!response.ok) { + throw new Error("Failed to create voucher"); + } + + return { + id: claimableId, + }; + }, +}); + +export const redeemWalletVoucher = createTool({ + name: "REDEEM_VOUCHER", + description: "Redeem a voucher for the current tenant's wallet", + inputSchema: z.object({ + voucher: z.string(), + }), + canAccess: canAccessWorkspaceResource, + handler: async ({ voucher }, ctx) => { + assertHasWorkspace(ctx); + + const wallet = getWalletClient(ctx); + + const parts = voucher.split("-"); + const voucherId = parts.slice(0, -1).join("-"); + const amountHintMicroDollars = parts.at(-1); + + if (!amountHintMicroDollars) { + throw new UserInputError("Invalid voucher ID"); + } + + const amountMicroDollars = MicroDollar.fromMicrodollarString( + amountHintMicroDollars, + ); + + if (amountMicroDollars.isZero() || amountMicroDollars.isNegative()) { + throw new UserInputError("Invalid voucher ID"); + } + + const operation = { + type: "WorkspaceRedeemVoucher" as const, + amount: amountMicroDollars.toMicrodollarString(), + voucherId, + workspace: ctx.workspace.value, + } as const; + + const response = await wallet["POST /transactions"]({}, { + body: operation, + }); + + if (!response.ok) { + throw new Error("Failed to redeem voucher"); + } + + return { + voucherId, + }; + }, +}); diff --git a/packages/sdk/src/wallets/client.ts b/packages/sdk/src/mcp/wallet/client.ts similarity index 82% rename from packages/sdk/src/wallets/client.ts rename to packages/sdk/src/mcp/wallet/client.ts index 5ffcecad..b9add89f 100644 --- a/packages/sdk/src/wallets/client.ts +++ b/packages/sdk/src/mcp/wallet/client.ts @@ -1,4 +1,4 @@ -import { createHttpClient } from "../http.ts"; +import { createHttpClient } from "../../http.ts"; import { MicroDollar } from "./microdollar.ts"; export interface DoubleEntry { @@ -12,6 +12,12 @@ export interface TransactionOperation { timestamp: Date; } +export interface WorkspaceCashIn extends TransactionOperation { + type: "WorkspaceCashIn"; + amount: number | string; + workspace: string; +} + export interface CashIn extends TransactionOperation { type: "CashIn"; amount: number | string; @@ -102,6 +108,12 @@ export interface GenCreditsReward extends TransactionOperation { userId: string; } +export interface WorkspaceGenCreditReward extends TransactionOperation { + type: "WorkspaceGenCreditReward"; + amount: number | string; + workspace: string; +} + interface BaseGeneration extends TransactionOperation { generatedBy: User; payer?: Payer; @@ -143,9 +155,13 @@ export type Transaction = | Generation | AgentGeneration | CashIn + | WorkspaceCashIn | CashOut | Wiretransfer | GenCreditsReward + | WorkspaceGenCreditReward + | WorkspaceCreateVoucher + | WorkspaceRedeemVoucher | PreAuthorization | CommitPreAuthorized; @@ -159,6 +175,20 @@ export interface Wiretransfer extends TransactionOperation { description?: string; } +export interface WorkspaceCreateVoucher extends TransactionOperation { + type: "WorkspaceCreateVoucher"; + amount: number | string; + voucherId: string; + workspace: string; +} + +export interface WorkspaceRedeemVoucher extends TransactionOperation { + type: "WorkspaceRedeemVoucher"; + amount: number | string; + voucherId: string; + workspace: string; +} + export interface GeneratedFact { id: string; transaction: Transaction; @@ -239,19 +269,6 @@ export interface WalletAPI { nextCursor?: string; }; }; - "GET /insights/generations": { - searchParams: { - userId: string; - }; - response: { - total: string; - dataPoints: { - app: string; - day: string; - amount: string; - }[]; - }; - }; "POST /transactions/:id/commit": { body: { mcpId: string; @@ -270,6 +287,40 @@ export interface WalletAPI { id: string; }; }; + "GET /usage/agents": { + searchParams: { + workspace: string; + range: "day" | "week" | "month"; + }; + response: { + total: string; + items: { + id: string; + label: string; + total: string; + }[]; + }; + }; + "GET /usage/threads": { + searchParams: { + workspace: string; + range: "day" | "week" | "month"; + }; + response: { + total: string; + items: { + id: string; + total: string; + agentId: string; + generatedBy: string; + tokens: { + totalTokens: number; + promptTokens: number; + completionTokens: number; + }; + }[]; + }; + }; } // for local dev diff --git a/packages/sdk/src/mcp/wallet/currencyApi.ts b/packages/sdk/src/mcp/wallet/currencyApi.ts new file mode 100644 index 00000000..22454f97 --- /dev/null +++ b/packages/sdk/src/mcp/wallet/currencyApi.ts @@ -0,0 +1,36 @@ +/** + * Currency API Client + * + * HTTP client for the Currency API service that provides real-time currency data. + * We use the small plan which includes 15,000 API requests per month. + * + * @see https://currencyapi.com/docs + */ +import { createHttpClient } from "../../http.ts"; + +interface CurrencyAPI { + "GET /latest": { + searchParams: { + currencies?: string[]; + base_currency?: string; + }; + response: { + data: { + [key: string]: { + value: number; + }; + }; + }; + }; +} + +export const createCurrencyClient = (apiKey: string) => { + const client = createHttpClient({ + base: "https://api.currencyapi.com/v3", + headers: new Headers({ + "apikey": apiKey, + }), + }); + + return client; +}; diff --git a/packages/sdk/src/wallets/index.ts b/packages/sdk/src/mcp/wallet/index.ts similarity index 76% rename from packages/sdk/src/wallets/index.ts rename to packages/sdk/src/mcp/wallet/index.ts index b4054049..733d4a4b 100644 --- a/packages/sdk/src/wallets/index.ts +++ b/packages/sdk/src/mcp/wallet/index.ts @@ -1,3 +1,4 @@ export { createWalletClient, type WalletAPI } from "./client.ts"; export { WellKnownTransactions, WellKnownWallets } from "./wellKnown.ts"; export { MicroDollar } from "./microdollar.ts"; +export { createCurrencyClient } from "./currencyApi.ts"; diff --git a/packages/sdk/src/wallets/microdollar.ts b/packages/sdk/src/mcp/wallet/microdollar.ts similarity index 100% rename from packages/sdk/src/wallets/microdollar.ts rename to packages/sdk/src/mcp/wallet/microdollar.ts diff --git a/packages/sdk/src/mcp/wallet/stripe/README.md b/packages/sdk/src/mcp/wallet/stripe/README.md new file mode 100644 index 00000000..46cd6468 --- /dev/null +++ b/packages/sdk/src/mcp/wallet/stripe/README.md @@ -0,0 +1,21 @@ +# How to test Stripe locally? + +1. Install stripe CLI. Ref: https://docs.stripe.com/stripe-cli + +2. Start the webhook local thing forwarding to the correct API endpoint: + +`stripe listen --forward-to localhost:3001/webhooks/stripe` + +3. Make sure you have the otherwise optional environment variables: + +- STRIPE_SECRET_KEY +- STRIPE_WEBHOOK_SECRET (This one will be outputted by the CLI on the previous + step) +- CURRENCY_API_KEY +- TESTING_CUSTOMER_ID (If you want to test Events for a specific customer) + +4. You can now run the `/apps/api` application and start firing webhook events. + +I have included sample events at `/apps/api/testing/stripe/fixtures`. You can +run them using `deno run test:stripe` at the `/apps/api` CWD. You should add +more if needed. diff --git a/packages/sdk/src/mcp/wallet/stripe/checkout.ts b/packages/sdk/src/mcp/wallet/stripe/checkout.ts new file mode 100644 index 00000000..90226041 --- /dev/null +++ b/packages/sdk/src/mcp/wallet/stripe/checkout.ts @@ -0,0 +1,238 @@ +import Stripe from "stripe"; +import { AppContext } from "../../context.ts"; +import { assertHasWorkspace } from "../../assertions.ts"; +import { createCurrencyClient } from "../index.ts"; + +const getStripeClient = (secretKey: string) => { + return new Stripe(secretKey, { + apiVersion: "2025-03-31.basil", + httpClient: Stripe.createFetchHttpClient(), + }); +}; + +/** + * Get or create a Stripe customer for the workspace. + * + * The relation between the workspace and the Stripe customer is stored in our + * database, in the `deco_chat_customer` table. + * + * The endpoint handling the stripe webhook will use this relation + * to update the workspace balance. + */ +const getOrCreateWorkspaceStripeCustomer = async ( + stripe: Stripe, + ctx: AppContext, +): Promise => { + assertHasWorkspace(ctx); + + const workspace = ctx.workspace.value; + + const { data: maybeCustomer } = await ctx.db.from("deco_chat_customer") + .select("customer_id").eq("workspace", workspace).maybeSingle(); + + if (maybeCustomer) { + const customer = await stripe.customers.retrieve(maybeCustomer.customer_id); + + if (customer.deleted) { + throw new Error("Stripe customer is deleted"); + } + + return customer; + } + + const customer = await stripe.customers.create({ + metadata: { + product: "deco.chat", + workspace, + }, + }); + + await ctx.db.from("deco_chat_customer").insert({ + customer_id: customer.id, + workspace, + }); + + return customer; +}; + +const convertUSDToBRL = async ({ + amountUSDCents: amountUSD, + currencyAPIKey, +}: { + amountUSDCents: number; + currencyAPIKey: string; +}): Promise => { + const currencyClient = createCurrencyClient(currencyAPIKey); + + const response = await currencyClient["GET /latest"]({ + currencies: ["BRL"], + }); + + if (!response.ok) { + console.error( + "[Stripe Checkout Session] Error fetching currency", + response, + ); + throw new Error("Internal server error: Failed to fetch currency"); + } + + const currency = await response.json(); + + const amountInBrl = Math.ceil(amountUSD * currency.data.BRL.value); + + return amountInBrl; +}; + +const MANDATORY_CUSTOM_FIELDS: + Stripe.Checkout.SessionCreateParams.CustomField[] = [ + { + label: { + custom: "Tax ID/CNPJ/CPF", + type: "custom", + }, + key: "tax_id", + type: "text", + optional: false, + }, + ]; + +interface WorkspaceWalletDeposit { + id: "WorkspaceWalletDeposit"; + /** + * Amount in cents + */ + amountUSD: number; +} + +type Product = WorkspaceWalletDeposit; + +type ProductHandler

= ( + product: P, + stripe: Stripe, + ctx: AppContext, +) => Promise>; + +const MINIMUM_AMOUNT_IN_USD_CENTS = 200; + +const handleWorkspaceWalletDeposit: ProductHandler = + async ( + product, + stripe, + ctx, + ) => { + if (!ctx.envVars.CURRENCY_API_KEY) { + throw new Error("CURRENCY_API_KEY is not set"); + } + + if ( + Number.isNaN(product.amountUSD) || + product.amountUSD < MINIMUM_AMOUNT_IN_USD_CENTS + ) { + throw new Error("Invalid amount"); + } + + const customer = await getOrCreateWorkspaceStripeCustomer(stripe, ctx); + + /** + * Since our Stripe account is based on Brazil and i want to make use of + * stripe adaptive pricing, we need to create a checkout with the amount + * in cents to BRL. Adaptive pricing will handle showing the local currency + * to the customer. + * + * At the moment, we show an input field for the customer to enter the + * amount in USD, so we use this API for converting the amount to BRL. + */ + const amountInCents = product.amountUSD; + const amountInBrl = await convertUSDToBRL({ + amountUSDCents: amountInCents, + currencyAPIKey: ctx.envVars.CURRENCY_API_KEY, + }); + const unitAmount = amountInBrl; + + return { + mode: "payment", + customer: customer.id, + adaptive_pricing: { + enabled: true, + }, + line_items: [{ + price_data: { + currency: "brl", + product_data: { + name: "Deco.chat Credits", + }, + unit_amount: unitAmount, + }, + quantity: 1, + }], + }; + }; + +const argsFor = ({ + product, + stripe, + ctx, +}: { + product: Product; + stripe: Stripe; + ctx: AppContext; +}): Promise> => { + const productHandlers: Record< + Product["id"], + ProductHandler + > = { + WorkspaceWalletDeposit: handleWorkspaceWalletDeposit, + }; + + const handler = productHandlers[product.id]; + + if (!handler) { + throw new Error(`No product found for ${product.id}`); + } + + return handler(product, stripe, ctx); +}; + +interface CreateCheckoutSessionArgs { + successUrl: string; + cancelUrl: string; + product: Product; + metadata?: Record; + ctx: AppContext; +} + +export const createCheckoutSession = async ({ + successUrl, + cancelUrl, + product, + metadata, + ctx, +}: CreateCheckoutSessionArgs) => { + if (!ctx.envVars.STRIPE_SECRET_KEY) { + throw new Error("STRIPE_SECRET_KEY is not set"); + } + + const stripe = getStripeClient(ctx.envVars.STRIPE_SECRET_KEY); + + const args = await argsFor({ + stripe, + product, + ctx, + }); + + const session = await stripe.checkout.sessions.create({ + ...args, + success_url: successUrl, + cancel_url: cancelUrl, + custom_fields: [ + ...(args.custom_fields ?? []), + ...MANDATORY_CUSTOM_FIELDS, + ], + metadata: { + ...args.metadata, + ...metadata, + }, + }); + + return session; +}; diff --git a/packages/sdk/src/mcp/wallet/stripe/webhook.ts b/packages/sdk/src/mcp/wallet/stripe/webhook.ts new file mode 100644 index 00000000..0a320d74 --- /dev/null +++ b/packages/sdk/src/mcp/wallet/stripe/webhook.ts @@ -0,0 +1,156 @@ +import Stripe from "stripe"; +import { WebhookEventIgnoredError } from "../../../errors.ts"; +import type { AppContext } from "../../context.ts"; +import { Transaction } from "../client.ts"; +import { createCurrencyClient, MicroDollar } from "../index.ts"; + +export const verifyAndParseStripeEvent = ( + payload: string, + signature: string, + c: AppContext, +): Promise => { + if (!c.envVars.STRIPE_SECRET_KEY || !c.envVars.STRIPE_WEBHOOK_SECRET) { + throw new Error("STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET is not set"); + } + + const stripe = new Stripe(c.envVars.STRIPE_SECRET_KEY, { + apiVersion: "2025-03-31.basil", + httpClient: Stripe.createFetchHttpClient(), + }); + + return stripe.webhooks.constructEventAsync( + payload, + signature, + c.envVars.STRIPE_WEBHOOK_SECRET, + ); +}; + +export class EventIgnoredError extends Error { + constructor(message: string) { + super(message); + this.name = "EventIgnoredError"; + } +} + +type EventHandler = ( + context: AppContext, + event: T, +) => Promise; + +const CURRENCIES_BESIDES_DOLLAR = ["BRL", "EUR"]; + +async function getCurrencies(c: AppContext) { + if (!c.envVars.CURRENCY_API_KEY) { + throw new Error("CURRENCY_API_KEY is not set"); + } + + const currencyClient = createCurrencyClient(c.envVars.CURRENCY_API_KEY); + const response = await currencyClient["GET /latest"]({ + currencies: [CURRENCIES_BESIDES_DOLLAR.join(",")], + }); + const data = await response.json(); + return data.data; +} + +async function getAmountInDollars({ + context, + amountReceivedUSDCents, + currency, +}: { + context: AppContext; + amountReceivedUSDCents: number; + currency: string; +}) { + const currencies = { + ...(await getCurrencies(context)), + USD: { value: 1 }, + }; + + if ( + !Object.keys(currencies).includes(currency.toUpperCase()) + ) { + throw new Error("Currency not supported"); + } + + const conversionRate = + currencies[currency.toUpperCase() as keyof typeof currencies].value; + + const amount = amountReceivedUSDCents / 100; + const microDollarsString = String( + Math.round((amount / conversionRate) * 1_000_000), + ); + + return MicroDollar.fromMicrodollarString(microDollarsString); +} + +async function getWorkspaceByCustomerId({ + context, + customerId: argsCustomerId, +}: { + context: AppContext; + customerId: string; +}): Promise { + const customerId = context.envVars.TESTING_CUSTOMER_ID || argsCustomerId; + const { data, error } = await context.db.from("deco_chat_customer").select( + "workspace", + ).eq("customer_id", customerId).maybeSingle(); + + if (!data || error) { + throw new Error("Failed to get workspace by customer ID", { + cause: error, + }); + } + + return data.workspace; +} + +const paymentIntentSucceeded: EventHandler = + async ( + context, + event, + ) => { + const customerId = event.data.object.customer; + + if (!customerId || typeof customerId !== "string") { + throw new Error("Customer ID not found or is not a string"); + } + + const [amount, workspace] = await Promise.all([ + getAmountInDollars({ + context, + amountReceivedUSDCents: event.data.object.amount_received, + currency: event.data.object.currency, + }), + getWorkspaceByCustomerId({ + context, + customerId, + }), + ]); + + return { + type: "WorkspaceCashIn", + amount: amount.toMicrodollarString(), + workspace, + timestamp: new Date(), + }; + }; + +export const createTransactionFromStripeEvent = ( + c: AppContext, + event: Stripe.Event, +): Promise => { + // deno-lint-ignore no-explicit-any + const handlers: Record> = { + "payment_intent.succeeded": paymentIntentSucceeded, + }; + + const handler = handlers[event.type as keyof typeof handlers]; + + if (!handler) { + throw new WebhookEventIgnoredError( + `No handler found for event type: ${event.type}`, + ); + } + + return handler(c, event); +}; diff --git a/packages/sdk/src/wallets/wellKnown.ts b/packages/sdk/src/mcp/wallet/wellKnown.ts similarity index 63% rename from packages/sdk/src/wallets/wellKnown.ts rename to packages/sdk/src/mcp/wallet/wellKnown.ts index 2141a229..e654e8d4 100644 --- a/packages/sdk/src/wallets/wellKnown.ts +++ b/packages/sdk/src/mcp/wallet/wellKnown.ts @@ -11,13 +11,22 @@ export const WellKnownWallets = { const [discriminator, type] = discriminatorAndCategory.split("@"); return { type, discriminator, category }; }, - user: { + workspace: { genCredits: ( - userId: string, + workspace: string, ) => [ "user" as const, - `gen-credits-${userId}`, + `workspace-gen-credits-${workspace}`, + "liability" as const, + ] as const, + voucher: ( + id: string, + amount: string, + ) => + [ + "user" as const, + `deco-chat-voucher-${id}-${amount}`, "liability" as const, ] as const, }, @@ -25,6 +34,6 @@ export const WellKnownWallets = { export const WellKnownTransactions = { freeTwoDollars: ( - userId: string, - ) => `free-two-dollars-${userId}`, + workspaceId: string, + ) => `free-two-dollars-${workspaceId}`, } as const; diff --git a/supabase/.gitignore b/supabase/.gitignore index ad9264f0..65e6e514 100644 --- a/supabase/.gitignore +++ b/supabase/.gitignore @@ -6,3 +6,8 @@ .env.keys .env.local .env.*.local + +# Supabase +.branches +.temp +.env diff --git a/supabase/migrations/20250521151003_add-deco-chat-customer-table.sql b/supabase/migrations/20250521151003_add-deco-chat-customer-table.sql index 77c5c7c6..1e26644d 100644 --- a/supabase/migrations/20250521151003_add-deco-chat-customer-table.sql +++ b/supabase/migrations/20250521151003_add-deco-chat-customer-table.sql @@ -12,4 +12,4 @@ CREATE INDEX IF NOT EXISTS idx_deco_chat_customer_workspace CREATE INDEX IF NOT EXISTS idx_deco_chat_customer_customer_id ON deco_chat_customer (customer_id); -ALTER TABLE deco_chat_customer ENABLE ROW LEVEL SECURITY; \ No newline at end of file +ALTER TABLE deco_chat_customer ENABLE ROW LEVEL SECURITY;