Skip to content
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
200 changes: 138 additions & 62 deletions apps/builder/src/features/admin/components/InvoicesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,9 +24,18 @@ const statusStyles: Record<string, string> = {
OVERDUE: "bg-red-3 text-red-11",
};

const paymentStatusStyles: Record<string, string> = {
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<Invoice | null>(null);
const [expandedInvoiceId, setExpandedInvoiceId] = useState<string | null>(
null,
);

const voidInvoice = useMutation(
orpc.invoice.void.mutationOptions({
Expand Down Expand Up @@ -56,68 +68,102 @@ export const InvoicesPanel = ({ workspaceId, invoices }: Props) => {
</Table.Row>
</Table.Header>
<Table.Body>
{invoices.map((invoice) => (
<Table.Row key={invoice.id}>
<Table.Cell className="text-xs">
{formatDate(invoice.periodStart)} –{" "}
{formatDate(invoice.periodEnd)}
</Table.Cell>
<Table.Cell>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusStyles[invoice.status] ?? ""}`}
>
{invoice.status}
</span>
</Table.Cell>
<Table.Cell>
${Number(invoice.baseAmountUsd).toFixed(2)}
</Table.Cell>
<Table.Cell>
{Number(invoice.overageAmountUsd) > 0 ? (
<span className="text-orange-11">
+${Number(invoice.overageAmountUsd).toFixed(4)}
</span>
) : (
"—"
)}
</Table.Cell>
<Table.Cell className="font-medium">
${Number(invoice.totalUsd).toFixed(2)}
</Table.Cell>
<Table.Cell className="text-xs">
{invoice.dueAt ? formatDate(invoice.dueAt) : "—"}
</Table.Cell>
<Table.Cell className="text-xs">
{invoice.paidAt ? formatDate(invoice.paidAt) : "—"}
</Table.Cell>
<Table.Cell>
<div className="flex gap-1">
{invoice.status !== "PAID" && invoice.status !== "VOID" && (
<Button
size="xs"
variant="outline"
onClick={() => setSelectedInvoice(invoice)}
{invoices.map((invoice) => {
const isExpanded = expandedInvoiceId === invoice.id;
return (
<Fragment key={invoice.id}>
<Table.Row>
<Table.Cell className="text-xs">
{formatDate(invoice.periodStart)} –{" "}
{formatDate(invoice.periodEnd)}
</Table.Cell>
<Table.Cell>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusStyles[invoice.status] ?? ""}`}
>
Mark paid
</Button>
)}
{invoice.status !== "VOID" && invoice.status !== "PAID" && (
<Button
size="xs"
variant="ghost"
disabled={voidInvoice.isPending}
onClick={() => {
if (confirm("Void this invoice?"))
voidInvoice.mutate({ invoiceId: invoice.id });
}}
>
Void
</Button>
)}
</div>
</Table.Cell>
</Table.Row>
))}
{invoice.status}
</span>
</Table.Cell>
<Table.Cell>
${Number(invoice.baseAmountUsd).toFixed(2)}
</Table.Cell>
<Table.Cell>
{Number(invoice.overageAmountUsd) > 0 ? (
<span className="text-orange-11">
+${Number(invoice.overageAmountUsd).toFixed(4)}
</span>
) : (
"—"
)}
</Table.Cell>
<Table.Cell className="font-medium">
${Number(invoice.totalUsd).toFixed(2)}
</Table.Cell>
<Table.Cell className="text-xs">
{invoice.dueAt ? formatDate(invoice.dueAt) : "—"}
</Table.Cell>
<Table.Cell className="text-xs">
{invoice.paidAt ? formatDate(invoice.paidAt) : "—"}
</Table.Cell>
<Table.Cell>
<div className="flex gap-1 items-center">
{invoice.payments.length > 0 && (
<Button
size="xs"
variant="ghost"
onClick={() =>
setExpandedInvoiceId(
isExpanded ? null : invoice.id,
)
}
>
<ArrowDown01Icon
className={cn(
"transition-transform",
isExpanded ? "" : "-rotate-90",
)}
/>
{invoice.payments.length} payment
{invoice.payments.length > 1 ? "s" : ""}
</Button>
)}
{invoice.status !== "PAID" &&
invoice.status !== "VOID" && (
<Button
size="xs"
variant="outline"
onClick={() => setSelectedInvoice(invoice)}
>
Mark paid
</Button>
)}
{invoice.status !== "VOID" &&
invoice.status !== "PAID" && (
<Button
size="xs"
variant="ghost"
disabled={voidInvoice.isPending}
onClick={() => {
if (confirm("Void this invoice?"))
voidInvoice.mutate({ invoiceId: invoice.id });
}}
>
Void
</Button>
)}
</div>
</Table.Cell>
</Table.Row>
{isExpanded && (
<Table.Row>
<Table.Cell colSpan={8} className="bg-gray-2">
<PaymentsSubRow payments={invoice.payments} />
</Table.Cell>
</Table.Row>
)}
</Fragment>
);
})}
</Table.Body>
</Table.Root>
)}
Expand All @@ -134,6 +180,36 @@ export const InvoicesPanel = ({ workspaceId, invoices }: Props) => {
);
};

const PaymentsSubRow = ({ payments }: { payments: Payment[] }) => (
<div className="flex flex-col gap-1.5 py-1">
{payments.map((payment) => (
<div
key={payment.id}
className="flex items-center gap-3 text-xs text-gray-11"
>
<span className="font-medium text-gray-12">{payment.method}</span>
<span
className={cn(
"inline-flex items-center px-2 py-0.5 rounded-full font-medium",
paymentStatusStyles[payment.status] ?? "",
)}
>
{payment.status}
</span>
<span>${Number(payment.amountUsd).toFixed(2)}</span>
{payment.providerRef && (
<span className="text-gray-10">ref: {payment.providerRef}</span>
)}
<span className="text-gray-10">
{payment.confirmedAt
? `confirmed ${formatDate(payment.confirmedAt)}`
: "not confirmed"}
</span>
</div>
))}
</div>
);

const formatDate = (date: Date | string) =>
new Date(date).toLocaleDateString("en-US", {
year: "numeric",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <NimblerbotBillingLayout workspace={workspace} />;

return (
<div className="flex flex-col gap-10 w-full">
<UsageProgressBars workspace={workspace} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <Skeleton className="h-2 w-full" />;

if (!data || data.includedAiCreditUsd === 0) return null;

const usedPercentage = Math.min(
100,
Math.round((data.rawCostUsd / data.includedAiCreditUsd) * 100),
);

return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-xl">AI usage</h3>
<p>
<span className="font-bold">${data.rawCostUsd.toFixed(2)}</span> / $
{data.includedAiCreditUsd.toFixed(2)} included
</p>
</div>
<Progress.Root value={usedPercentage} />
{data.overageUsd > 0 && (
<p className="text-sm text-orange-11">
Overage accruing this period: +${data.overageUsd.toFixed(2)}
</p>
)}
{data.aiHardCeilingUsd !== null && (
<p className="text-sm italic text-gray-9">
AI usage pauses at ${data.aiHardCeilingUsd.toFixed(2)} this period.
</p>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-3">
{paymentLink && (
<Button
render={(props) => (
<a
{...props}
href={paymentLink}
target="_blank"
rel="noopener noreferrer"
>
Pay ${totalUsd.toFixed(2)} with DeUna
</a>
)}
/>
)}
<Accordion.Root>
<Accordion.Item value="bank-transfer">
<Accordion.Trigger>Pay by bank transfer</Accordion.Trigger>
<Accordion.Panel className="text-sm text-gray-11">
<p>
Transfer{" "}
<span className="font-medium">${totalUsd.toFixed(2)}</span> to:
</p>
<ul className="flex flex-col gap-0.5">
<li>Bank: {bankTransferDetails.bankName}</li>
<li>
{bankTransferDetails.accountType} #
{bankTransferDetails.accountNumber}
</li>
<li>Beneficiary: {bankTransferDetails.beneficiary}</li>
<li>{bankTransferDetails.taxId}</li>
</ul>
<p>
Use invoice <span className="font-mono">{invoiceId}</span> as the
transfer reference, then email proof to{" "}
<a
className="underline"
href={`mailto:${billingContact.supportEmail}?subject=Payment for invoice ${invoiceId}`}
>
{billingContact.supportEmail}
</a>
. We confirm payments manually within one business day.
</p>
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
</div>
);
};
Loading