diff --git a/.env.example b/.env.example index 88c118659e..05552a849c 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,13 @@ NEXT_PUBLIC_VIEWER_URL= ADMIN_EMAIL= # For more configuration options check out: https://docs.typebot.io/self-hosting/configuration + +# Nimblerbot billing UI — contact + bank-transfer details shown to customers. +# These are never stored in version control; set them in Infisical (or .env.local). +NEXT_PUBLIC_NIMBLERBOT_BILLING_SUPPORT_EMAIL= +NEXT_PUBLIC_NIMBLERBOT_BILLING_SALES_EMAIL= +NEXT_PUBLIC_NIMBLERBOT_BILLING_BANK_NAME= +NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_TYPE= +NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_NUMBER= +NEXT_PUBLIC_NIMBLERBOT_BILLING_BENEFICIARY= +NEXT_PUBLIC_NIMBLERBOT_BILLING_TAX_ID= diff --git a/apps/builder/src/features/admin/components/InvoicesPanel.tsx b/apps/builder/src/features/admin/components/InvoicesPanel.tsx index c5dc5ee4fe..0de0cb8279 100644 --- a/apps/builder/src/features/admin/components/InvoicesPanel.tsx +++ b/apps/builder/src/features/admin/components/InvoicesPanel.tsx @@ -1,12 +1,15 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@typebot.io/ui/components/Button"; import { Table } from "@typebot.io/ui/components/Table"; -import { useState } from "react"; +import { ArrowDown01Icon } from "@typebot.io/ui/icons/ArrowDown01Icon"; +import { cn } from "@typebot.io/ui/lib/cn"; +import { Fragment, useState } from "react"; import { orpc } from "@/lib/queryClient"; import type { AdminWorkspace } from "../types"; import { ConfirmPaymentDialog } from "./ConfirmPaymentDialog"; type Invoice = AdminWorkspace["invoices"][number]; +type Payment = Invoice["payments"][number]; type Props = { workspaceId: string; @@ -21,9 +24,18 @@ const statusStyles: Record = { OVERDUE: "bg-red-3 text-red-11", }; +const paymentStatusStyles: Record = { + PENDING: "bg-blue-3 text-blue-11", + CONFIRMED: "bg-green-3 text-green-11", + FAILED: "bg-red-3 text-red-11", +}; + export const InvoicesPanel = ({ workspaceId, invoices }: Props) => { const queryClient = useQueryClient(); const [selectedInvoice, setSelectedInvoice] = useState(null); + const [expandedInvoiceId, setExpandedInvoiceId] = useState( + null, + ); const voidInvoice = useMutation( orpc.invoice.void.mutationOptions({ @@ -56,68 +68,102 @@ export const InvoicesPanel = ({ workspaceId, invoices }: Props) => { - {invoices.map((invoice) => ( - - - {formatDate(invoice.periodStart)} –{" "} - {formatDate(invoice.periodEnd)} - - - - {invoice.status} - - - - ${Number(invoice.baseAmountUsd).toFixed(2)} - - - {Number(invoice.overageAmountUsd) > 0 ? ( - - +${Number(invoice.overageAmountUsd).toFixed(4)} - - ) : ( - "—" - )} - - - ${Number(invoice.totalUsd).toFixed(2)} - - - {invoice.dueAt ? formatDate(invoice.dueAt) : "—"} - - - {invoice.paidAt ? formatDate(invoice.paidAt) : "—"} - - -
- {invoice.status !== "PAID" && invoice.status !== "VOID" && ( - - )} - {invoice.status !== "VOID" && invoice.status !== "PAID" && ( - - )} -
-
-
- ))} + {invoice.status} + + + + ${Number(invoice.baseAmountUsd).toFixed(2)} + + + {Number(invoice.overageAmountUsd) > 0 ? ( + + +${Number(invoice.overageAmountUsd).toFixed(4)} + + ) : ( + "—" + )} + + + ${Number(invoice.totalUsd).toFixed(2)} + + + {invoice.dueAt ? formatDate(invoice.dueAt) : "—"} + + + {invoice.paidAt ? formatDate(invoice.paidAt) : "—"} + + +
+ {invoice.payments.length > 0 && ( + + )} + {invoice.status !== "PAID" && + invoice.status !== "VOID" && ( + + )} + {invoice.status !== "VOID" && + invoice.status !== "PAID" && ( + + )} +
+
+ + {isExpanded && ( + + + + + + )} + + ); + })}
)} @@ -134,6 +180,36 @@ export const InvoicesPanel = ({ workspaceId, invoices }: Props) => { ); }; +const PaymentsSubRow = ({ payments }: { payments: Payment[] }) => ( +
+ {payments.map((payment) => ( +
+ {payment.method} + + {payment.status} + + ${Number(payment.amountUsd).toFixed(2)} + {payment.providerRef && ( + ref: {payment.providerRef} + )} + + {payment.confirmedAt + ? `confirmed ${formatDate(payment.confirmedAt)}` + : "not confirmed"} + +
+ ))} +
+); + const formatDate = (date: Date | string) => new Date(date).toLocaleDateString("en-US", { year: "numeric", diff --git a/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx b/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx index a5e6cd8a3b..af135daa6e 100644 --- a/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx +++ b/apps/builder/src/features/billing/components/BillingSettingsLayout.tsx @@ -1,13 +1,19 @@ import { useWorkspace } from "@/features/workspace/WorkspaceProvider"; +import { isNimblerbotInstance } from "@/helpers/isNimblerbotInstance"; import { ChangePlanForm } from "./ChangePlanForm"; import { CurrentSubscriptionSummary } from "./CurrentSubscriptionSummary"; import { InvoicesList } from "./InvoicesList"; +import { NimblerbotBillingLayout } from "./nimblerbot/NimblerbotBillingLayout"; import { UsageProgressBars } from "./UsageProgressBars"; export const BillingSettingsLayout = () => { const { workspace, currentUserMode } = useWorkspace(); if (!workspace) return null; + + if (isNimblerbotInstance()) + return ; + return (
diff --git a/apps/builder/src/features/billing/components/nimblerbot/AiUsageBar.tsx b/apps/builder/src/features/billing/components/nimblerbot/AiUsageBar.tsx new file mode 100644 index 0000000000..bc4e6a77b7 --- /dev/null +++ b/apps/builder/src/features/billing/components/nimblerbot/AiUsageBar.tsx @@ -0,0 +1,50 @@ +import { useQuery } from "@tanstack/react-query"; +import { Progress } from "@typebot.io/ui/components/Progress"; +import { Skeleton } from "@typebot.io/ui/components/Skeleton"; +import { orpc } from "@/lib/queryClient"; + +type Props = { + workspaceId: string; +}; + +// Customer-facing AI usage. Labels are intentionally generic ("AI usage", +// "included AI credit") — the underlying provider is never surfaced here. +export const AiUsageBar = ({ workspaceId }: Props) => { + const { data, isLoading } = useQuery( + orpc.subscription.getAiUsage.queryOptions({ + input: { workspaceId }, + }), + ); + + if (isLoading) return ; + + if (!data || data.includedAiCreditUsd === 0) return null; + + const usedPercentage = Math.min( + 100, + Math.round((data.rawCostUsd / data.includedAiCreditUsd) * 100), + ); + + return ( +
+
+

AI usage

+

+ ${data.rawCostUsd.toFixed(2)} / $ + {data.includedAiCreditUsd.toFixed(2)} included +

+
+ + {data.overageUsd > 0 && ( +

+ Overage accruing this period: +${data.overageUsd.toFixed(2)} +

+ )} + {data.aiHardCeilingUsd !== null && ( +

+ AI usage pauses at ${data.aiHardCeilingUsd.toFixed(2)} this period. +

+ )} +
+ ); +}; diff --git a/apps/builder/src/features/billing/components/nimblerbot/InvoicePaymentAction.tsx b/apps/builder/src/features/billing/components/nimblerbot/InvoicePaymentAction.tsx new file mode 100644 index 0000000000..faffe8add9 --- /dev/null +++ b/apps/builder/src/features/billing/components/nimblerbot/InvoicePaymentAction.tsx @@ -0,0 +1,71 @@ +import { Accordion } from "@typebot.io/ui/components/Accordion"; +import { Button } from "@typebot.io/ui/components/Button"; +import { bankTransferDetails, billingContact } from "./nimblerbotBillingConfig"; + +type Props = { + invoiceId: string; + status: string; + totalUsd: number; + paymentLink: string | null; +}; + +// Payment options for an unpaid invoice. DeUna link only appears once a link +// has been generated (Phase 3.4 wiring); bank transfer is always available. +export const InvoicePaymentAction = ({ + invoiceId, + status, + totalUsd, + paymentLink, +}: Props) => { + if (status !== "ISSUED" && status !== "OVERDUE") return null; + + return ( +
+ {paymentLink && ( +
+ ); +}; diff --git a/apps/builder/src/features/billing/components/nimblerbot/NimblerbotBillingLayout.tsx b/apps/builder/src/features/billing/components/nimblerbot/NimblerbotBillingLayout.tsx new file mode 100644 index 0000000000..e5937ca8e2 --- /dev/null +++ b/apps/builder/src/features/billing/components/nimblerbot/NimblerbotBillingLayout.tsx @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { Skeleton } from "@typebot.io/ui/components/Skeleton"; +import type { WorkspaceInApp } from "@/features/workspace/WorkspaceProvider"; +import { orpc } from "@/lib/queryClient"; +import { AiUsageBar } from "./AiUsageBar"; +import { NimblerbotInvoicesList } from "./NimblerbotInvoicesList"; +import { NimblerbotPlanCards } from "./NimblerbotPlanCards"; +import { SubscriptionSummaryCard } from "./SubscriptionSummaryCard"; + +type Props = { + workspace: WorkspaceInApp; +}; + +export const NimblerbotBillingLayout = ({ workspace }: Props) => { + const { data: subscription, isLoading } = useQuery( + orpc.subscription.getByWorkspace.queryOptions({ + input: { workspaceId: workspace.id }, + }), + ); + + if (isLoading) return ; + + if (!subscription) + return ( +
+

+ You're on the Starter plan. Contact us to upgrade to Business or + Enterprise. +

+ +
+ ); + + return ( +
+ + + + +
+ ); +}; diff --git a/apps/builder/src/features/billing/components/nimblerbot/NimblerbotInvoicesList.tsx b/apps/builder/src/features/billing/components/nimblerbot/NimblerbotInvoicesList.tsx new file mode 100644 index 0000000000..b86594b7f2 --- /dev/null +++ b/apps/builder/src/features/billing/components/nimblerbot/NimblerbotInvoicesList.tsx @@ -0,0 +1,107 @@ +import { useQuery } from "@tanstack/react-query"; +import { Accordion } from "@typebot.io/ui/components/Accordion"; +import { Badge } from "@typebot.io/ui/components/Badge"; +import { Skeleton } from "@typebot.io/ui/components/Skeleton"; +import { orpc } from "@/lib/queryClient"; +import { InvoicePaymentAction } from "./InvoicePaymentAction"; + +type Props = { + workspaceId: string; +}; + +const statusColor: Record = { + DRAFT: "gray", + ISSUED: "blue", + PAID: "green", + VOID: "gray", + OVERDUE: "red", +}; + +export const NimblerbotInvoicesList = ({ workspaceId }: Props) => { + const { data, isLoading } = useQuery( + orpc.invoice.list.queryOptions({ + input: { workspaceId, take: 12 }, + }), + ); + + if (isLoading) return ; + + const invoices = data?.items ?? []; + + return ( +
+

Invoices

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

No invoices yet

+ ) : ( + + {invoices.map((invoice) => ( + + +
+ + {formatDate(invoice.periodStart)} –{" "} + {formatDate(invoice.periodEnd)} + +
+ + {invoice.status} + + + ${Number(invoice.totalUsd).toFixed(2)} + +
+
+
+ +
    + {invoice.lineItems.map((lineItem) => ( +
  • + + {lineItem.description} + + ${Number(lineItem.amountUsd).toFixed(2)} +
  • + ))} +
+
+ {invoice.dueAt && ( + Due {formatDate(invoice.dueAt)} + )} + {invoice.paidAt && ( + Paid {formatDate(invoice.paidAt)} + )} +
+ {invoice.payments.length > 0 && ( +
    + {invoice.payments.map((payment) => ( +
  • + {payment.method} · {payment.status} + {payment.confirmedAt + ? ` · ${formatDate(payment.confirmedAt)}` + : ""} +
  • + ))} +
+ )} + +
+
+ ))} +
+ )} +
+ ); +}; + +const formatDate = (date: Date | string) => + new Date(date).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); diff --git a/apps/builder/src/features/billing/components/nimblerbot/NimblerbotPlanCards.tsx b/apps/builder/src/features/billing/components/nimblerbot/NimblerbotPlanCards.tsx new file mode 100644 index 0000000000..b6a0ea6ee9 --- /dev/null +++ b/apps/builder/src/features/billing/components/nimblerbot/NimblerbotPlanCards.tsx @@ -0,0 +1,83 @@ +import type { Plan } from "@typebot.io/prisma/enum"; +import { tierConfig } from "@typebot.io/subscriptions/tiers"; +import { Badge } from "@typebot.io/ui/components/Badge"; +import { Button } from "@typebot.io/ui/components/Button"; +import { cn } from "@typebot.io/ui/lib/cn"; +import { billingContact } from "./nimblerbotBillingConfig"; + +type Props = { + currentTier: Plan; +}; + +type CardSpec = { + plan: Plan; + name: string; + priceLabel: string; + blurb: string; + cta: { label: string; href: string } | null; +}; + +const cards: CardSpec[] = [ + { + plan: "FREE", + name: "Starter", + priceLabel: "$0/mo", + blurb: `${tierConfig.FREE.botLimit} bots · bring your own AI key`, + cta: null, + }, + { + plan: "BUSINESS", + name: "Business", + priceLabel: `$${tierConfig.BUSINESS.priceUsd}/mo`, + blurb: `${tierConfig.BUSINESS.botLimit} bots · included AI credit · advanced modules · priority support`, + cta: { + label: "Contact us", + href: `mailto:${billingContact.salesEmail}?subject=Upgrade to Business`, + }, + }, + { + plan: "ENTERPRISE", + name: "Enterprise", + priceLabel: "Custom", + blurb: "Unlimited bots · dedicated support · SLA · whitelabel", + cta: { + label: "Talk to Sales", + href: `mailto:${billingContact.salesEmail}?subject=Enterprise plan`, + }, + }, +]; + +export const NimblerbotPlanCards = ({ currentTier }: Props) => ( +
+

Plans

+
+ {cards.map((card) => { + const isCurrent = card.plan === currentTier; + return ( +
+
+

{card.name}

+ {isCurrent && Current} +
+

{card.priceLabel}

+

{card.blurb}

+ {!isCurrent && card.cta && ( + + )} +
+ ); + })} +
+
+); diff --git a/apps/builder/src/features/billing/components/nimblerbot/SubscriptionSummaryCard.tsx b/apps/builder/src/features/billing/components/nimblerbot/SubscriptionSummaryCard.tsx new file mode 100644 index 0000000000..c767d35d79 --- /dev/null +++ b/apps/builder/src/features/billing/components/nimblerbot/SubscriptionSummaryCard.tsx @@ -0,0 +1,53 @@ +import type { Plan } from "@typebot.io/prisma/enum"; +import { tierConfig } from "@typebot.io/subscriptions/tiers"; +import { Badge } from "@typebot.io/ui/components/Badge"; + +type Props = { + tier: Plan; + status: string; + currentPeriodStart: Date | string; + currentPeriodEnd: Date | string; +}; + +const statusLabel: Record = { + ACTIVE: "Active", + IN_GRACE: "Payment due", + QUARANTINED: "Suspended for non-payment", + CANCELED: "Canceled", +}; + +export const SubscriptionSummaryCard = ({ + tier, + status, + currentPeriodStart, + currentPeriodEnd, +}: Props) => { + const priceUsd = tierConfig[tier]?.priceUsd ?? null; + + return ( +
+
+
+

{tier}

+ + {statusLabel[status] ?? status} + +
+

+ {priceUsd === null ? "Custom" : `$${priceUsd}/mo`} +

+
+

+ Current billing period: {formatDate(currentPeriodStart)} –{" "} + {formatDate(currentPeriodEnd)} +

+
+ ); +}; + +const formatDate = (date: Date | string) => + new Date(date).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); diff --git a/apps/builder/src/features/billing/components/nimblerbot/nimblerbotBillingConfig.ts b/apps/builder/src/features/billing/components/nimblerbot/nimblerbotBillingConfig.ts new file mode 100644 index 0000000000..1e2d99b68d --- /dev/null +++ b/apps/builder/src/features/billing/components/nimblerbot/nimblerbotBillingConfig.ts @@ -0,0 +1,14 @@ +import { env } from "@typebot.io/env"; + +export const billingContact = { + salesEmail: env.NEXT_PUBLIC_NIMBLERBOT_BILLING_SALES_EMAIL ?? "", + supportEmail: env.NEXT_PUBLIC_NIMBLERBOT_BILLING_SUPPORT_EMAIL ?? "", +}; + +export const bankTransferDetails = { + bankName: env.NEXT_PUBLIC_NIMBLERBOT_BILLING_BANK_NAME ?? "", + accountType: env.NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_TYPE ?? "", + accountNumber: env.NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_NUMBER ?? "", + beneficiary: env.NEXT_PUBLIC_NIMBLERBOT_BILLING_BENEFICIARY ?? "", + taxId: env.NEXT_PUBLIC_NIMBLERBOT_BILLING_TAX_ID ?? "", +}; diff --git a/apps/builder/src/features/typebot/api/handleImportTypebot.ts b/apps/builder/src/features/typebot/api/handleImportTypebot.ts index 5f4c7f8eaa..f7af029fc7 100644 --- a/apps/builder/src/features/typebot/api/handleImportTypebot.ts +++ b/apps/builder/src/features/typebot/api/handleImportTypebot.ts @@ -4,6 +4,7 @@ import { copyObjects } from "@typebot.io/lib/s3/copyObjects"; import { replaceTypebotUploadUrlsWithNewIds } from "@typebot.io/lib/s3/replaceTypebotUploadUrlsWithNewIds"; import prisma from "@typebot.io/prisma"; import { Plan } from "@typebot.io/prisma/enum"; +import { assertBlocksEntitled } from "@typebot.io/subscriptions/assertBlocksEntitled"; import { getTypebotsLimit } from "@typebot.io/subscriptions/getTypebotsLimit"; import { isOverBotLimit } from "@typebot.io/subscriptions/isOverBotLimit"; import { isPlanEntitledForTemplate } from "@typebot.io/subscriptions/isPlanEntitledForTemplate"; @@ -147,6 +148,9 @@ export const handleImportTypebot = async ({ newUploadUrlsResponse.typebot, ); + if (duplicatingBot.groups) + assertBlocksEntitled(workspace.plan, duplicatingBot.groups); + const groups = ( duplicatingBot.groups ? await sanitizeGroups(duplicatingBot.groups, { diff --git a/apps/builder/src/features/typebot/api/handlePublishTypebot.ts b/apps/builder/src/features/typebot/api/handlePublishTypebot.ts index 576d6daa8d..5ce6506268 100644 --- a/apps/builder/src/features/typebot/api/handlePublishTypebot.ts +++ b/apps/builder/src/features/typebot/api/handlePublishTypebot.ts @@ -12,6 +12,7 @@ import { } from "@typebot.io/runtime-session-store"; import { isTypebotVersionAtLeastV6 } from "@typebot.io/schemas/helpers/isTypebotVersionAtLeastV6"; import { settingsSchema } from "@typebot.io/settings/schemas"; +import { assertBlocksEntitled } from "@typebot.io/subscriptions/assertBlocksEntitled"; import type { TelemetryEvent } from "@typebot.io/telemetry/schemas"; import { sendMessage } from "@typebot.io/telemetry/sendMessage"; import { trackEvents } from "@typebot.io/telemetry/trackEvents"; @@ -90,6 +91,13 @@ export const handlePublishTypebot = async ({ message: "File upload blocks can't be published on the free plan", }); + assertBlocksEntitled( + existingTypebot.workspace.plan, + parseGroups(existingTypebot.groups, { + typebotVersion: existingTypebot.version, + }), + ); + const typebotWasVerified = existingTypebot.riskLevel === -1 || existingTypebot.workspace.isVerified; diff --git a/apps/builder/src/features/typebot/api/handleUpdateTypebot.ts b/apps/builder/src/features/typebot/api/handleUpdateTypebot.ts index 84100d6ab2..98046e79ee 100644 --- a/apps/builder/src/features/typebot/api/handleUpdateTypebot.ts +++ b/apps/builder/src/features/typebot/api/handleUpdateTypebot.ts @@ -2,6 +2,7 @@ import { ORPCError } from "@orpc/server"; import prisma from "@typebot.io/prisma"; import { DbNull } from "@typebot.io/prisma/enum"; import { settingsSchema } from "@typebot.io/settings/schemas"; +import { assertBlocksEntitled } from "@typebot.io/subscriptions/assertBlocksEntitled"; import { migrateTypebot } from "@typebot.io/typebot/migrations/migrateTypebot"; import { typebotSchema, @@ -146,6 +147,9 @@ export const handleUpdateTypebot = async ({ }); } + if (typebot.groups) + assertBlocksEntitled(existingTypebot.workspace.plan, typebot.groups); + const groups = typebot.groups ? await sanitizeGroups(typebot.groups, { workspace: existingTypebot.workspace, diff --git a/apps/builder/src/helpers/isNimblerbotInstance.ts b/apps/builder/src/helpers/isNimblerbotInstance.ts new file mode 100644 index 0000000000..cfc2e87109 --- /dev/null +++ b/apps/builder/src/helpers/isNimblerbotInstance.ts @@ -0,0 +1,18 @@ +import { env } from "@typebot.io/env"; + +// Nimblerbot is always self-hosted and never uses Stripe, so the only +// environment that should keep the upstream Stripe billing UI is the Typebot +// cloud. Unlike `isSelfHostedInstance`, this treats localhost as a Nimblerbot +// instance so the custom billing UI renders (and is testable) in local dev. +export const isNimblerbotInstance = () => { + if (typeof window !== "undefined") { + return ( + window.location.hostname !== "app.typebot.com" && + window.location.hostname !== "app.typebot.io" + ); + } + return ( + env.NEXTAUTH_URL !== "https://app.typebot.com" && + env.NEXTAUTH_URL !== "https://app.typebot.io" + ); +}; diff --git a/apps/builder/src/pages/past-due.tsx b/apps/builder/src/pages/past-due.tsx index 932a1186a4..f572d1256d 100644 --- a/apps/builder/src/pages/past-due.tsx +++ b/apps/builder/src/pages/past-due.tsx @@ -1,13 +1,18 @@ +import { Button } from "@typebot.io/ui/components/Button"; +import { useOpenControls } from "@typebot.io/ui/hooks/useOpenControls"; import { TriangleAlertIcon } from "@typebot.io/ui/icons/TriangleAlertIcon"; import { useRouter } from "next/router"; import { useEffect } from "react"; -import { BillingPortalButton } from "@/features/billing/components/BillingPortalButton"; import { DashboardHeader } from "@/features/dashboard/components/DashboardHeader"; +import { useUser } from "@/features/user/hooks/useUser"; +import { WorkspaceSettingsDialog } from "@/features/workspace/components/WorkspaceSettingsDialog"; import { useWorkspace } from "@/features/workspace/WorkspaceProvider"; export default function Page() { const { replace } = useRouter(); + const { user } = useUser(); const { workspace } = useWorkspace(); + const { isOpen, onOpen, onClose } = useOpenControls(); useEffect(() => { if (!workspace || workspace.isPastDue) return; @@ -17,11 +22,26 @@ export default function Page() { return ( <> -
+
-

Your workspace has unpaid invoice(s).

-

Head over to the billing portal to pay it.

- {workspace?.id && } +

Your workspace has an unpaid invoice.

+

+ Pay it by bank transfer or DeUna to keep your bots running. You have a + 7-day grace period — after that bots stop answering until payment is + confirmed. +

+ {user && workspace && ( + <> + + + + )}
); diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 2cc43a8b11..352fbced6b 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -478,6 +478,44 @@ const inngestEnv = { }, }; +const nimblerbotEnv = { + client: { + NEXT_PUBLIC_NIMBLERBOT_BILLING_SUPPORT_EMAIL: z.string().email().optional(), + NEXT_PUBLIC_NIMBLERBOT_BILLING_SALES_EMAIL: z.string().email().optional(), + NEXT_PUBLIC_NIMBLERBOT_BILLING_BANK_NAME: z.string().min(1).optional(), + NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_TYPE: z.string().min(1).optional(), + NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_NUMBER: z + .string() + .min(1) + .optional(), + NEXT_PUBLIC_NIMBLERBOT_BILLING_BENEFICIARY: z.string().min(1).optional(), + NEXT_PUBLIC_NIMBLERBOT_BILLING_TAX_ID: z.string().min(1).optional(), + }, + runtimeEnv: { + NEXT_PUBLIC_NIMBLERBOT_BILLING_SUPPORT_EMAIL: getRuntimeVariable( + "NEXT_PUBLIC_NIMBLERBOT_BILLING_SUPPORT_EMAIL", + ), + NEXT_PUBLIC_NIMBLERBOT_BILLING_SALES_EMAIL: getRuntimeVariable( + "NEXT_PUBLIC_NIMBLERBOT_BILLING_SALES_EMAIL", + ), + NEXT_PUBLIC_NIMBLERBOT_BILLING_BANK_NAME: getRuntimeVariable( + "NEXT_PUBLIC_NIMBLERBOT_BILLING_BANK_NAME", + ), + NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_TYPE: getRuntimeVariable( + "NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_TYPE", + ), + NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_NUMBER: getRuntimeVariable( + "NEXT_PUBLIC_NIMBLERBOT_BILLING_ACCOUNT_NUMBER", + ), + NEXT_PUBLIC_NIMBLERBOT_BILLING_BENEFICIARY: getRuntimeVariable( + "NEXT_PUBLIC_NIMBLERBOT_BILLING_BENEFICIARY", + ), + NEXT_PUBLIC_NIMBLERBOT_BILLING_TAX_ID: getRuntimeVariable( + "NEXT_PUBLIC_NIMBLERBOT_BILLING_TAX_ID", + ), + }, +}; + const otelEnv = { server: { OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), @@ -543,6 +581,7 @@ export const env = createEnv({ ...posthogEnv.client, ...tolgeeEnv.client, ...partykitEnv.client, + ...nimblerbotEnv.client, }, experimental__runtimeEnv: { ...baseEnv.runtimeEnv, @@ -557,6 +596,7 @@ export const env = createEnv({ ...posthogEnv.runtimeEnv, ...tolgeeEnv.runtimeEnv, ...partykitEnv.runtimeEnv, + ...nimblerbotEnv.runtimeEnv, }, skipValidation: process.env.SKIP_ENV_CHECK === "true" || diff --git a/packages/prisma/postgresql/migrations/20260620000000_add_workspace_suspend_reason_and_audit_log/migration.sql b/packages/prisma/postgresql/migrations/20260620000000_add_workspace_suspend_reason_and_audit_log/migration.sql new file mode 100644 index 0000000000..8ba6470586 --- /dev/null +++ b/packages/prisma/postgresql/migrations/20260620000000_add_workspace_suspend_reason_and_audit_log/migration.sql @@ -0,0 +1,19 @@ +-- Persist the staff-entered suspension reason on the workspace. +ALTER TABLE "Workspace" ADD COLUMN "suspendReason" TEXT; + +-- Append-only audit log of staff lifecycle actions on a workspace. +CREATE TABLE "WorkspaceAuditLog" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "workspaceId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "reason" TEXT, + "performedByUserId" TEXT, + "performedByEmail" TEXT, + + CONSTRAINT "WorkspaceAuditLog_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "WorkspaceAuditLog_workspaceId_createdAt_idx" ON "WorkspaceAuditLog"("workspaceId", "createdAt"); + +ALTER TABLE "WorkspaceAuditLog" ADD CONSTRAINT "WorkspaceAuditLog_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/postgresql/schema.prisma b/packages/prisma/postgresql/schema.prisma index 238f0436a3..b9cbdb13a9 100644 --- a/packages/prisma/postgresql/schema.prisma +++ b/packages/prisma/postgresql/schema.prisma @@ -103,6 +103,7 @@ model Workspace { customSeatsLimit Int? isQuarantined Boolean @default(false) isSuspended Boolean @default(false) + suspendReason String? isPastDue Boolean @default(false) isVerified Boolean? themeTemplates ThemeTemplate[] @@ -117,6 +118,7 @@ model Workspace { invoices Invoice[] payments Payment[] llmUsageLogs LLMUsageLog[] + auditLogs WorkspaceAuditLog[] } model Space { @@ -599,3 +601,19 @@ model LLMUsageLog { @@index([workspaceId, createdAt]) } + +// Append-only record of staff lifecycle actions on a workspace (suspend, +// unsuspend, …) so non-payment/abuse history stays queryable. The actor is +// stored as scalars (no User FK) to keep the change localized to billing. +model WorkspaceAuditLog { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + action String + reason String? + performedByUserId String? + performedByEmail String? + + @@index([workspaceId, createdAt]) +} diff --git a/packages/results/src/workflows/rpc.test.ts b/packages/results/src/workflows/rpc.test.ts index 4f0250636b..c26ef1583e 100644 --- a/packages/results/src/workflows/rpc.test.ts +++ b/packages/results/src/workflows/rpc.test.ts @@ -44,7 +44,7 @@ describe("ExecuteExportResultsWorkflow", () => { const mockWorkflowLayer = ExportResultsWorkflow.toLayer( Effect.fn(function* () { - yield* Effect.sleep("50 millis"); + yield* Effect.sleep("300 millis"); return { fileUrl: new URL("http://example.com/file.csv"), typebotName: "Test Typebot", @@ -62,15 +62,15 @@ describe("ExecuteExportResultsWorkflow", () => { testPayload, ).pipe(Stream.runCollect, Effect.forkChild); - yield* Effect.sleep("5 millis"); + yield* Effect.sleep("50 millis"); yield* Queue.offer(progressQueue, "0"); - yield* Effect.sleep("5 millis"); + yield* Effect.sleep("10 millis"); yield* Queue.offer(progressQueue, "25"); - yield* Effect.sleep("5 millis"); + yield* Effect.sleep("10 millis"); yield* Queue.offer(progressQueue, "50"); - yield* Effect.sleep("5 millis"); + yield* Effect.sleep("10 millis"); yield* Queue.offer(progressQueue, "75"); - yield* Effect.sleep("5 millis"); + yield* Effect.sleep("10 millis"); yield* Queue.offer(progressQueue, "100"); return yield* Fiber.join(streamFiber); @@ -99,7 +99,7 @@ describe("ExecuteExportResultsWorkflow", () => { status: "completed", fileUrl: "http://example.com/file.csv", }); - }); + }, 5_000); it("should fail when workflow fails (and not hang)", async () => { const progressQueue = await Effect.runPromise(Queue.unbounded()); diff --git a/packages/subscriptions/src/api/adminRouter.ts b/packages/subscriptions/src/api/adminRouter.ts index 317a46e9bf..46121673ae 100644 --- a/packages/subscriptions/src/api/adminRouter.ts +++ b/packages/subscriptions/src/api/adminRouter.ts @@ -1,5 +1,6 @@ import prisma from "@typebot.io/prisma"; import { z } from "zod"; +import { setWorkspaceSuspension } from "../setWorkspaceSuspension"; import { staffProcedure } from "./staffProcedure"; export const adminRouter = { @@ -128,20 +129,23 @@ export const adminRouter = { suspendWorkspace: staffProcedure .input(z.object({ workspaceId: z.string(), reason: z.string().min(1) })) - .handler(async ({ input }) => { - await prisma.workspace.update({ - where: { id: input.workspaceId }, - data: { isSuspended: true }, + .handler(async ({ input, context }) => { + await setWorkspaceSuspension({ + workspaceId: input.workspaceId, + isSuspended: true, + reason: input.reason, + performedBy: context.user, }); return { success: true }; }), unsuspendWorkspace: staffProcedure .input(z.object({ workspaceId: z.string() })) - .handler(async ({ input }) => { - await prisma.workspace.update({ - where: { id: input.workspaceId }, - data: { isSuspended: false }, + .handler(async ({ input, context }) => { + await setWorkspaceSuspension({ + workspaceId: input.workspaceId, + isSuspended: false, + performedBy: context.user, }); return { success: true }; }), diff --git a/packages/subscriptions/src/api/assertWorkspaceMember.ts b/packages/subscriptions/src/api/assertWorkspaceMember.ts new file mode 100644 index 0000000000..d982a5572b --- /dev/null +++ b/packages/subscriptions/src/api/assertWorkspaceMember.ts @@ -0,0 +1,20 @@ +import { ORPCError } from "@orpc/server"; +import prisma from "@typebot.io/prisma"; +import { isStaff } from "../staff"; + +// Guards a customer-accessible billing route: the caller must be a member of +// the workspace, unless they are staff (ADMIN_EMAIL allowlist). Staff need the +// bypass because the admin console reads the same routes for any workspace. +export const assertWorkspaceMember = async ( + user: { id: string; email?: string | null }, + workspaceId: string, + db = prisma, +): Promise => { + if (isStaff(user)) return; + const member = await db.memberInWorkspace.findFirst({ + where: { workspaceId, userId: user.id }, + select: { userId: true }, + }); + if (!member) + throw new ORPCError("NOT_FOUND", { message: "Workspace not found" }); +}; diff --git a/packages/subscriptions/src/api/router.ts b/packages/subscriptions/src/api/router.ts index 1f41ea97c1..697b435a1e 100644 --- a/packages/subscriptions/src/api/router.ts +++ b/packages/subscriptions/src/api/router.ts @@ -5,6 +5,8 @@ import { activateSubscription } from "../activate"; import { cancelSubscription } from "../cancel"; import { changeTier } from "../changeTier"; import { confirmInvoicePayment } from "../confirmInvoicePayment"; +import { getAiUsage } from "../getAiUsage"; +import { assertWorkspaceMember } from "./assertWorkspaceMember"; import { staffProcedure } from "./staffProcedure"; const activatablePlanSchema = z.enum(["BUSINESS", "ENTERPRISE"]); @@ -47,12 +49,20 @@ export const subscriptionRouter = { getByWorkspace: authenticatedProcedure .input(z.object({ workspaceId: z.string() })) - .handler(async ({ input }) => { + .handler(async ({ input, context }) => { + await assertWorkspaceMember(context.user, input.workspaceId); return prisma.subscription.findUnique({ where: { workspaceId: input.workspaceId }, include: { invoices: { orderBy: { createdAt: "desc" }, take: 12 } }, }); }), + + getAiUsage: authenticatedProcedure + .input(z.object({ workspaceId: z.string() })) + .handler(async ({ input, context }) => { + await assertWorkspaceMember(context.user, input.workspaceId); + return getAiUsage(input.workspaceId); + }), }, invoice: { @@ -64,8 +74,9 @@ export const subscriptionRouter = { cursor: z.string().optional(), }), ) - .handler(async ({ input }) => { + .handler(async ({ input, context }) => { const { workspaceId, take, cursor } = input; + await assertWorkspaceMember(context.user, workspaceId); const items = await prisma.invoice.findMany({ where: { workspaceId }, orderBy: { createdAt: "desc" }, diff --git a/packages/subscriptions/src/assertBlocksEntitled.test.ts b/packages/subscriptions/src/assertBlocksEntitled.test.ts new file mode 100644 index 0000000000..1462088a08 --- /dev/null +++ b/packages/subscriptions/src/assertBlocksEntitled.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { Plan } from "@typebot.io/prisma/enum"; +import { assertBlocksEntitled } from "./assertBlocksEntitled"; + +const groupsWithCalCom = [{ blocks: [{ type: "text" }, { type: "cal-com" }] }]; +const groupsWithoutCalCom = [ + { blocks: [{ type: "text" }, { type: "open-router" }] }, +]; + +describe("assertBlocksEntitled", () => { + it("throws for FREE when a cal-com block is present", () => { + expect(() => assertBlocksEntitled(Plan.FREE, groupsWithCalCom)).toThrow(); + }); + + it("allows BUSINESS to use a cal-com block", () => { + expect(() => + assertBlocksEntitled(Plan.BUSINESS, groupsWithCalCom), + ).not.toThrow(); + }); + + it("allows ENTERPRISE to use a cal-com block", () => { + expect(() => + assertBlocksEntitled(Plan.ENTERPRISE, groupsWithCalCom), + ).not.toThrow(); + }); + + it("does not throw for FREE when no gated block is present", () => { + expect(() => + assertBlocksEntitled(Plan.FREE, groupsWithoutCalCom), + ).not.toThrow(); + }); + + it("does not throw on an empty flow", () => { + expect(() => assertBlocksEntitled(Plan.FREE, [])).not.toThrow(); + }); +}); diff --git a/packages/subscriptions/src/assertBlocksEntitled.ts b/packages/subscriptions/src/assertBlocksEntitled.ts new file mode 100644 index 0000000000..ecdbe17dc6 --- /dev/null +++ b/packages/subscriptions/src/assertBlocksEntitled.ts @@ -0,0 +1,32 @@ +import { ORPCError } from "@orpc/server"; +import type { Plan } from "@typebot.io/prisma/enum"; +import { hasFeature } from "./features"; +import type { Feature } from "./tiers"; + +// Forged blocks whose use is gated behind a plan feature, keyed by the block +// `type` as it appears in a typebot's groups (the forge block id). +const gatedBlocks = [ + { type: "cal-com", feature: "calCom", label: "Cal.com" }, +] satisfies ReadonlyArray<{ type: string; feature: Feature; label: string }>; + +type GroupWithBlocks = { blocks: ReadonlyArray<{ type: string }> }; + +// Server-side counterpart to the client block-sidebar gate: rejects saving, +// importing, or publishing a flow that uses a feature-gated block the plan +// isn't entitled to. Closes the paste/import bypass of the sidebar-only gate. +export const assertBlocksEntitled = ( + plan: Plan, + groups: ReadonlyArray, +) => { + const usedBlockTypes = new Set( + groups.flatMap((group) => group.blocks.map((block) => block.type)), + ); + for (const gatedBlock of gatedBlocks) + if ( + usedBlockTypes.has(gatedBlock.type) && + !hasFeature(plan, gatedBlock.feature) + ) + throw new ORPCError("FORBIDDEN", { + message: `The ${gatedBlock.label} block requires the Business plan. Upgrade to use it.`, + }); +}; diff --git a/packages/subscriptions/src/changeTier.ts b/packages/subscriptions/src/changeTier.ts index 10173f84c6..712bad3e7e 100644 --- a/packages/subscriptions/src/changeTier.ts +++ b/packages/subscriptions/src/changeTier.ts @@ -1,22 +1,34 @@ +import { ORPCError } from "@orpc/server"; import prisma from "@typebot.io/prisma"; import type { ActivatablePlan } from "./activate"; -// Changes the tier of an existing subscription and keeps workspace.plan in -// sync, in one transaction. No new invoice is generated and proration is out of -// scope: the base-price change takes effect on the next billing cycle. +// Changes the tier of an existing, live subscription and keeps workspace.plan +// in sync, in one transaction. No new invoice is generated and proration is out +// of scope: the base-price change takes effect on the next billing cycle. A +// CANCELED (or absent) subscription must be re-activated, not tier-changed — +// this mirrors the admin UI, which routes those to the activation form. export const changeTier = async ( workspaceId: string, tier: ActivatablePlan, db: typeof prisma = prisma, ) => { - await db.$transaction([ - db.subscription.update({ + await db.$transaction(async (tx) => { + const subscription = await tx.subscription.findUnique({ + where: { workspaceId }, + select: { status: true }, + }); + if (!subscription || subscription.status === "CANCELED") + throw new ORPCError("BAD_REQUEST", { + message: + "Cannot change tier of a canceled subscription; reactivate it.", + }); + await tx.subscription.update({ where: { workspaceId }, data: { tier }, - }), - db.workspace.update({ + }); + await tx.workspace.update({ where: { id: workspaceId }, data: { plan: tier }, - }), - ]); + }); + }); }; diff --git a/packages/subscriptions/src/getAiUsage.ts b/packages/subscriptions/src/getAiUsage.ts new file mode 100644 index 0000000000..4bb9290f80 --- /dev/null +++ b/packages/subscriptions/src/getAiUsage.ts @@ -0,0 +1,57 @@ +import prisma from "@typebot.io/prisma"; +import { computeOverageUsd } from "./computeOverageUsd"; + +export type AiUsageSummary = { + rawCostUsd: number; + includedAiCreditUsd: number; + overageMarkupPct: number; + overageUsd: number; + aiHardCeilingUsd: number | null; + periodStart: Date; + periodEnd: Date; +}; + +// Current-period AI usage for a workspace, for the customer billing UI. +// Aggregates LLMUsageLog cost from the subscription's period start up to now +// and derives the accruing overage with the locked formula. Deliberately +// returns no model/provider names — usage is customer-facing and generic. +// Returns null when the workspace has no subscription. +export const getAiUsage = async ( + workspaceId: string, + db = prisma, +): Promise => { + const subscription = await db.subscription.findUnique({ + where: { workspaceId }, + }); + if (!subscription) return null; + + const usageAggregate = await db.lLMUsageLog.aggregate({ + where: { + workspaceId, + createdAt: { gte: subscription.currentPeriodStart }, + }, + _sum: { costUsd: true }, + }); + + const rawCostUsd = Number(usageAggregate._sum.costUsd ?? 0); + const includedAiCreditUsd = Number(subscription.includedAiCreditUsd); + const overageMarkupPct = Number(subscription.overageMarkupPct); + const aiHardCeilingUsd = + subscription.aiHardCeilingUsd === null + ? null + : Number(subscription.aiHardCeilingUsd); + + return { + rawCostUsd, + includedAiCreditUsd, + overageMarkupPct, + overageUsd: computeOverageUsd({ + rawCostUsd, + includedAiCreditUsd, + overageMarkupPct, + }), + aiHardCeilingUsd, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }; +}; diff --git a/packages/subscriptions/src/setWorkspaceSuspension.ts b/packages/subscriptions/src/setWorkspaceSuspension.ts new file mode 100644 index 0000000000..96874c4825 --- /dev/null +++ b/packages/subscriptions/src/setWorkspaceSuspension.ts @@ -0,0 +1,40 @@ +import prisma from "@typebot.io/prisma"; + +type SetWorkspaceSuspensionParams = { + workspaceId: string; + isSuspended: boolean; + reason?: string; + performedBy: { id: string; email?: string | null }; +}; + +// Suspends or reactivates a workspace and records the action in the append-only +// WorkspaceAuditLog, in one transaction. Suspension is reserved for ToS/abuse — +// non-payment uses the grace/quarantine flags instead. Reactivating clears the +// stored reason; history stays queryable via the audit log. +export const setWorkspaceSuspension = ( + { + workspaceId, + isSuspended, + reason, + performedBy, + }: SetWorkspaceSuspensionParams, + db = prisma, +) => + db.$transaction([ + db.workspace.update({ + where: { id: workspaceId }, + data: { + isSuspended, + suspendReason: isSuspended ? reason : null, + }, + }), + db.workspaceAuditLog.create({ + data: { + workspaceId, + action: isSuspended ? "SUSPEND" : "UNSUSPEND", + reason: isSuspended ? reason : undefined, + performedByUserId: performedBy.id, + performedByEmail: performedBy.email ?? undefined, + }, + }), + ]); diff --git a/packages/subscriptions/tests/billingApi.test.ts b/packages/subscriptions/tests/billingApi.test.ts new file mode 100644 index 0000000000..75e64eba8a --- /dev/null +++ b/packages/subscriptions/tests/billingApi.test.ts @@ -0,0 +1,105 @@ +import { randomUUID } from "node:crypto"; +import { beforeAll, describe, expect, it } from "vitest"; +import { activateSubscription } from "../src/activate"; +import { assertWorkspaceMember } from "../src/api/assertWorkspaceMember"; +import { getAiUsage } from "../src/getAiUsage"; +import { createTestWorkspace, testDb } from "./helpers"; + +let db: ReturnType; +beforeAll(() => { + db = testDb(); +}); + +const createTestUser = () => + db.user.create({ + data: { + email: `user-${randomUUID()}@example.com`, + onboardingCategories: [], + }, + }); + +describe("getAiUsage", () => { + it("returns null for a workspace with no subscription", async () => { + const workspace = await createTestWorkspace("FREE"); + expect(await getAiUsage(workspace.id, db)).toBeNull(); + }); + + it("aggregates period cost and derives the marked-up overage", async () => { + const workspace = await createTestWorkspace("FREE"); + const { subscription } = await activateSubscription( + workspace.id, + "BUSINESS", + { includedAiCreditUsd: 20, overageMarkupPct: 20 }, + db, + ); + + await db.lLMUsageLog.create({ + data: { + workspaceId: workspace.id, + model: "openai/gpt-4o", + provider: "openai", + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + costUsd: 30, + createdAt: new Date(subscription.currentPeriodStart.getTime() + 1000), + }, + }); + + const usage = await getAiUsage(workspace.id, db); + expect(usage).not.toBeNull(); + expect(usage!.rawCostUsd).toBeCloseTo(30); + expect(usage!.includedAiCreditUsd).toBe(20); + // (30 - 20) * 1.2 = 12 + expect(usage!.overageUsd).toBeCloseTo(12); + }); + + it("ignores usage logged before the current period start", async () => { + const workspace = await createTestWorkspace("FREE"); + const { subscription } = await activateSubscription( + workspace.id, + "BUSINESS", + {}, + db, + ); + + await db.lLMUsageLog.create({ + data: { + workspaceId: workspace.id, + model: "openai/gpt-4o", + provider: "openai", + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + costUsd: 99, + createdAt: new Date(subscription.currentPeriodStart.getTime() - 1000), + }, + }); + + const usage = await getAiUsage(workspace.id, db); + expect(usage!.rawCostUsd).toBe(0); + }); +}); + +describe("assertWorkspaceMember", () => { + it("resolves for a member of the workspace", async () => { + const workspace = await createTestWorkspace("FREE"); + const user = await createTestUser(); + await db.memberInWorkspace.create({ + data: { workspaceId: workspace.id, userId: user.id, role: "ADMIN" }, + }); + + await expect( + assertWorkspaceMember(user, workspace.id, db), + ).resolves.toBeUndefined(); + }); + + it("rejects a non-member with NOT_FOUND", async () => { + const workspace = await createTestWorkspace("FREE"); + const stranger = await createTestUser(); + + await expect( + assertWorkspaceMember(stranger, workspace.id, db), + ).rejects.toThrow(/not found/i); + }); +}); diff --git a/packages/subscriptions/tests/engine.test.ts b/packages/subscriptions/tests/engine.test.ts index 9ab8da1aac..2abc2b022e 100644 --- a/packages/subscriptions/tests/engine.test.ts +++ b/packages/subscriptions/tests/engine.test.ts @@ -98,6 +98,19 @@ describe("changeTier", () => { expect(sub.tier).toBe("ENTERPRISE"); expect(refreshed.plan).toBe("ENTERPRISE"); }); + + it("rejects a tier change on a canceled subscription", async () => { + const workspace = await createTestWorkspace("FREE"); + await activateSubscription(workspace.id, "BUSINESS", {}, db); + await cancelSubscription(workspace.id, db); + + await expect(changeTier(workspace.id, "ENTERPRISE", db)).rejects.toThrow(); + + const sub = await db.subscription.findUniqueOrThrow({ + where: { workspaceId: workspace.id }, + }); + expect(sub.tier).toBe("BUSINESS"); + }); }); describe("generatePeriodInvoice", () => { diff --git a/packages/subscriptions/tests/setWorkspaceSuspension.test.ts b/packages/subscriptions/tests/setWorkspaceSuspension.test.ts new file mode 100644 index 0000000000..33281839d4 --- /dev/null +++ b/packages/subscriptions/tests/setWorkspaceSuspension.test.ts @@ -0,0 +1,98 @@ +import { randomUUID } from "node:crypto"; +import { beforeAll, describe, expect, it } from "vitest"; +import { setWorkspaceSuspension } from "../src/setWorkspaceSuspension"; +import { createTestWorkspace, testDb } from "./helpers"; + +let db: ReturnType; +beforeAll(() => { + db = testDb(); +}); + +const staff = { id: "staff-user", email: "staff@example.com" }; + +describe("setWorkspaceSuspension", () => { + it("suspends a workspace, persists the reason, and audit-logs the action", async () => { + const workspace = await createTestWorkspace("BUSINESS"); + + await setWorkspaceSuspension( + { + workspaceId: workspace.id, + isSuspended: true, + reason: "ToS violation: spam", + performedBy: staff, + }, + db, + ); + + const updated = await db.workspace.findUniqueOrThrow({ + where: { id: workspace.id }, + }); + expect(updated.isSuspended).toBe(true); + expect(updated.suspendReason).toBe("ToS violation: spam"); + + const logs = await db.workspaceAuditLog.findMany({ + where: { workspaceId: workspace.id }, + }); + expect(logs).toHaveLength(1); + expect(logs[0]?.action).toBe("SUSPEND"); + expect(logs[0]?.reason).toBe("ToS violation: spam"); + expect(logs[0]?.performedByUserId).toBe(staff.id); + expect(logs[0]?.performedByEmail).toBe(staff.email); + }); + + it("reactivation clears the reason and records an UNSUSPEND entry", async () => { + const workspace = await createTestWorkspace("BUSINESS"); + await setWorkspaceSuspension( + { + workspaceId: workspace.id, + isSuspended: true, + reason: "abuse", + performedBy: staff, + }, + db, + ); + + await setWorkspaceSuspension( + { workspaceId: workspace.id, isSuspended: false, performedBy: staff }, + db, + ); + + const updated = await db.workspace.findUniqueOrThrow({ + where: { id: workspace.id }, + }); + expect(updated.isSuspended).toBe(false); + expect(updated.suspendReason).toBeNull(); + + const logs = await db.workspaceAuditLog.findMany({ + where: { workspaceId: workspace.id }, + orderBy: { createdAt: "asc" }, + }); + expect(logs.map((l) => l.action)).toEqual(["SUSPEND", "UNSUSPEND"]); + expect(logs[1]?.reason).toBeNull(); + }); + + it("keeps an append-only history across multiple suspensions", async () => { + const workspace = await createTestWorkspace("BUSINESS"); + const reasons = [`r1-${randomUUID()}`, `r2-${randomUUID()}`]; + for (const reason of reasons) { + await setWorkspaceSuspension( + { + workspaceId: workspace.id, + isSuspended: true, + reason, + performedBy: staff, + }, + db, + ); + await setWorkspaceSuspension( + { workspaceId: workspace.id, isSuspended: false, performedBy: staff }, + db, + ); + } + + const logs = await db.workspaceAuditLog.findMany({ + where: { workspaceId: workspace.id }, + }); + expect(logs).toHaveLength(4); + }); +}); diff --git a/packages/subscriptions/tests/setup.ts b/packages/subscriptions/tests/setup.ts index 9825e8b027..40ae86b833 100644 --- a/packages/subscriptions/tests/setup.ts +++ b/packages/subscriptions/tests/setup.ts @@ -4,3 +4,10 @@ import { inject } from "vitest"; // time. Point it at the shared testcontainer so importing engine functions // (which import the singleton) doesn't crash before our injected client is used. process.env.DATABASE_URL = inject("pgContainerDatabaseUri"); + +// Some api helpers (e.g. assertWorkspaceMember → staff) import @typebot.io/env, +// which validates a few required vars at import time. Provide dummy values so +// those imports don't throw under the test runner. +process.env.ENCRYPTION_SECRET ??= "0".repeat(32); +process.env.NEXTAUTH_URL ??= "http://localhost:3000"; +process.env.NEXT_PUBLIC_VIEWER_URL ??= "http://localhost:3001"; diff --git a/packages/workspaces/src/schemas.ts b/packages/workspaces/src/schemas.ts index 689ac3f2ed..14cefbdb52 100644 --- a/packages/workspaces/src/schemas.ts +++ b/packages/workspaces/src/schemas.ts @@ -49,6 +49,7 @@ export const workspaceSchema = z.object({ customSeatsLimit: z.number().nullable(), isQuarantined: z.boolean(), isSuspended: z.boolean(), + suspendReason: z.string().nullable(), isPastDue: z.boolean(), isVerified: z.boolean().nullable(), chatsHardLimit: z.number().nullable(),