diff --git a/.dockerignore b/.dockerignore index b0a11682..352fa2dd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,4 +10,4 @@ coverage bun.lock docs/ md/ -playwright.config.ts +playwright.config.ts \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b55d9e31..90a058ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,10 @@ jobs: echo "BILLING_CURRENCY=USD" >> $GITHUB_ENV echo "BYOB_ENCRYPTION_SECRET=test_secret_32_characters_long_123" >> $GITHUB_ENV echo "NEXT_PUBLIC_APPWRITE_BYOB_TENANTS_ID=byob_tenants" >> $GITHUB_ENV - echo "NEXT_PUBLIC_TRIAL_CREDIT_USD=30" >> $GITHUB_ENV - echo "TRIAL_CREDIT_DAYS=60" >> $GITHUB_ENV + echo "NEXT_PUBLIC_ORG_TRIAL_CREDIT_USD=30" >> $GITHUB_ENV + echo "ORG_TRIAL_CREDIT_DAYS=60" >> $GITHUB_ENV + echo "NEXT_PUBLIC_PERSONAL_TRIAL_CREDIT_USD=10" >> $GITHUB_ENV + echo "PERSONAL_TRIAL_CREDIT_DAYS=30" >> $GITHUB_ENV - name: Type check run: npx tsc --noEmit diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7bcb6ed5..a4a1d91b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -155,9 +155,13 @@ jobs: # Redis (server-side caching layer) REDIS_URL: ${{ secrets.REDIS_URL }} REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} - # Trial Credits - NEXT_PUBLIC_TRIAL_CREDIT_USD: ${{ vars.NEXT_PUBLIC_TRIAL_CREDIT_USD }} - TRIAL_CREDIT_DAYS: ${{ vars.TRIAL_CREDIT_DAYS }} + # Trial Credits (Granular Config) + NEXT_PUBLIC_ORG_TRIAL_CREDIT_USD: ${{ vars.NEXT_PUBLIC_ORG_TRIAL_CREDIT_USD }} + ORG_TRIAL_CREDIT_USD: ${{ vars.ORG_TRIAL_CREDIT_USD }} + ORG_TRIAL_CREDIT_DAYS: ${{ vars.ORG_TRIAL_CREDIT_DAYS }} + NEXT_PUBLIC_PERSONAL_TRIAL_CREDIT_USD: ${{ vars.NEXT_PUBLIC_PERSONAL_TRIAL_CREDIT_USD }} + PERSONAL_TRIAL_CREDIT_USD: ${{ vars.PERSONAL_TRIAL_CREDIT_USD }} + PERSONAL_TRIAL_CREDIT_DAYS: ${{ vars.PERSONAL_TRIAL_CREDIT_DAYS }} run: npm run build - name: Clean up before transfer @@ -298,8 +302,12 @@ jobs: R2_PUBLIC_URL=${{ vars.R2_PUBLIC_URL }} REDIS_URL=${{ secrets.REDIS_URL }} REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} - NEXT_PUBLIC_TRIAL_CREDIT_USD=${{ vars.NEXT_PUBLIC_TRIAL_CREDIT_USD }} - TRIAL_CREDIT_DAYS=${{ vars.TRIAL_CREDIT_DAYS }} + NEXT_PUBLIC_ORG_TRIAL_CREDIT_USD=${{ vars.NEXT_PUBLIC_ORG_TRIAL_CREDIT_USD }} + ORG_TRIAL_CREDIT_USD=${{ vars.ORG_TRIAL_CREDIT_USD }} + ORG_TRIAL_CREDIT_DAYS=${{ vars.ORG_TRIAL_CREDIT_DAYS }} + NEXT_PUBLIC_PERSONAL_TRIAL_CREDIT_USD=${{ vars.NEXT_PUBLIC_PERSONAL_TRIAL_CREDIT_USD }} + PERSONAL_TRIAL_CREDIT_USD=${{ vars.PERSONAL_TRIAL_CREDIT_USD }} + PERSONAL_TRIAL_CREDIT_DAYS=${{ vars.PERSONAL_TRIAL_CREDIT_DAYS }} EOF chmod 600 .env.local diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 4466cfa7..477c497c 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -59,7 +59,8 @@ const DashboardContent = ({ children }: DashboardLayoutProps) => { const isProfilePage = pathname === "/profile" || pathname.startsWith("/profile/"); const workspaceId = useWorkspaceId(); const isTaskDetailPage = /^\/workspaces\/[^\/]+\/tasks\/[^\/]+$/.test(pathname || ""); - const isMainDashboard = /^\/workspaces\/[^\/]+$/.test(pathname || ""); +const isWorkflowPage = /^\/workspaces\/[^\/]+\/spaces\/[^\/]+\/workflows\/[^\/]+$/.test(pathname || ""); + const isMainDashboard = /^\/workspaces\/[^\/]+$/.test(pathname || ""); return (
@@ -93,14 +94,12 @@ const DashboardContent = ({ children }: DashboardLayoutProps) => {
-
-
- {children} -
-
+
+ {children} +
diff --git a/src/app/(dashboard)/workspaces/[workspaceId]/admin/usage/client.tsx b/src/app/(dashboard)/workspaces/[workspaceId]/admin/usage/client.tsx index 451eee5b..dacd7bdf 100644 --- a/src/app/(dashboard)/workspaces/[workspaceId]/admin/usage/client.tsx +++ b/src/app/(dashboard)/workspaces/[workspaceId]/admin/usage/client.tsx @@ -33,6 +33,7 @@ import { useGetUsageDashboard, useExportUsage, } from "@/features/usage/api"; +import { useGetBillingAccount } from "@/features/billing/api"; import { UsageKPICards, UsageCharts, @@ -131,6 +132,13 @@ export function UsageDashboardClient() { eventsOffset: fetchOffset, }); + // Fetch billing account for wallet balance + const { data: billingAccountData } = useGetBillingAccount({ + organizationId: isOrg ? primaryOrganizationId : undefined, + userId: !isOrg ? user?.$id : undefined, + enabled: !!(isOrg ? primaryOrganizationId : user?.$id) + }); + const isEventsLoading = isDashboardLoading; const isSummaryLoading = isDashboardLoading; const isAlertsLoading = isDashboardLoading; @@ -367,6 +375,8 @@ export function UsageDashboardClient() { isLoading={isSummaryLoading} currency={currency} exchangeRate={rate} + walletBalance={billingAccountData?.walletBalance} + walletCurrency={billingAccountData?.walletCurrency} /> {/* Charts */} diff --git a/src/app/(dashboard)/workspaces/[workspaceId]/billing/client.tsx b/src/app/(dashboard)/workspaces/[workspaceId]/billing/client.tsx index d8c425c3..382389fc 100644 --- a/src/app/(dashboard)/workspaces/[workspaceId]/billing/client.tsx +++ b/src/app/(dashboard)/workspaces/[workspaceId]/billing/client.tsx @@ -1,28 +1,196 @@ "use client"; +import { useState, useCallback } from "react"; import { useParams } from "next/navigation"; -import { Building2, CreditCard, TrendingUp, Calendar } from "lucide-react"; +import { + Building2, + CreditCard, + TrendingUp, + Calendar, + Wallet, + ArrowUpRight, + Plus, + Loader2, + ExternalLink, + FileText, + Clock +} from "lucide-react"; +import { format, startOfMonth, endOfMonth, differenceInDays } from "date-fns"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { Input } from "@/components/ui/input"; import { useAccountType } from "@/features/organizations/hooks/use-account-type"; import { useGetOrganizations } from "@/features/organizations/api/use-get-organizations"; -import { useGetUsageSummary } from "@/features/usage/api/use-get-usage-summary"; -import { useGetUsageEvents } from "@/features/usage/api/use-get-usage-events"; +import { + useGetUsageSummary, + useGetUsageEvents +} from "@/features/usage/api"; import { WorkspaceUsageBreakdown } from "@/features/usage/components"; import { RedeemCouponCard } from "@/features/github-rewards/components/RedeemCouponCard"; +import { + useGetBillingAccount, + useSetupBilling, + useGetBillingStatus, + useGetInvoices +} from "@/features/billing/api"; +import { useCurrent } from "@/features/auth/api/use-current"; +import { BillingAccountType, BillingStatus, BillingInvoice } from "@/features/billing/types"; +import { client } from "@/lib/rpc"; +import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; +import Script from "next/script"; +import { cn } from "@/lib/utils"; export const BillingDashboardClient = () => { const params = useParams(); + const queryClient = useQueryClient(); const workspaceId = params.workspaceId as string; - const { isOrg, primaryOrganizationId, accountType } = useAccountType(); + const { isOrg, isPersonal, primaryOrganizationId, accountType } = useAccountType(); + const { data: user } = useCurrent(); const { data: organizations } = useGetOrganizations(); const { data: usageSummary, isLoading: usageLoading } = useGetUsageSummary({ workspaceId, period: new Date().toISOString().slice(0, 7), // Current month YYYY-MM }); + // Billing hooks + const { data: billingAccountData } = useGetBillingAccount({ + organizationId: isOrg ? primaryOrganizationId : undefined, + userId: isPersonal ? user?.$id : undefined, + enabled: !!(isOrg ? primaryOrganizationId : user?.$id) + }); + const { mutateAsync: setupBilling } = useSetupBilling(); + + const [isAddingCredits, setIsAddingCredits] = useState(false); + const [isScriptLoaded, setIsScriptLoaded] = useState(false); + const [topupAmount, setTopupAmount] = useState(""); + + // Wallet balance + const walletBalance = billingAccountData?.walletBalance ?? 0; + const walletCurrency = billingAccountData?.walletCurrency ?? "USD"; + const trialExpiresAt = billingAccountData?.trialExpiresAt; + const initialTrialAmount = billingAccountData?.initialTrialAmount ?? 0; + + // Credit breakdown logic + // Trial credits are consumed first. + // Remaining Trial = min(current balance, initial trial amount) + // Real Credits = current balance - remaining trial + const trialBalance = Math.min(walletBalance, initialTrialAmount); + const realBalance = Math.max(0, walletBalance - trialBalance); + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: walletCurrency, + minimumFractionDigits: 2, + }).format(amount); + }; + + // Handler for adding credits + const handleAddCredits = useCallback(async () => { + const entityId = isOrg ? primaryOrganizationId : user?.$id; + if (!entityId) { + toast.error("Account ID is required"); + return; + } + + const amount = Number(topupAmount); + if (!amount || amount < 1) { + toast.error("Minimum top-up amount is $1"); + return; + } + + if (!isScriptLoaded) { + toast.error("Payment system not ready. Please refresh the page."); + return; + } + + setIsAddingCredits(true); + + try { + // Ensure billing account exists + if (!billingAccountData?.data) { + try { + await setupBilling({ + json: { + type: isOrg ? BillingAccountType.ORG : BillingAccountType.PERSONAL, + organizationId: isOrg ? primaryOrganizationId : undefined, + userId: isPersonal ? user?.$id : undefined, + billingEmail: user?.email || undefined, + contactName: user?.name || "User", + } + }); + } catch { + toast.error("Failed to initialize billing. Please contact support."); + setIsAddingCredits(false); + return; + } + } + + // Create order + const orderResponse = await client.api.wallet["create-order"].$post({ + json: { + amount, + organizationId: isOrg ? primaryOrganizationId : undefined, + userId: isPersonal ? user?.$id : undefined, + }, + }); + + if (!orderResponse.ok) { + const errorData = await orderResponse.json().catch(() => ({})); + throw new Error((errorData as { error?: string }).error || "Failed to create order"); + } + + const orderResult = await orderResponse.json() as { + data: { + orderId: string; + paymentSessionId: string; + environment: string; + } + }; + const orderData = orderResult.data; + + const cashfree = (window as Window & { + Cashfree: (args: { mode: string }) => { + checkout: (args: { paymentSessionId: string; redirectTarget: string }) => Promise<{ error?: { message?: string } }> + } + }).Cashfree({ + mode: orderData.environment || "sandbox" + }); + + const checkoutResult = await cashfree.checkout({ + paymentSessionId: orderData.paymentSessionId, + redirectTarget: "_modal", + }); + + if (checkoutResult.error) { + if (!checkoutResult.error.message?.includes("user")) { + toast.error(`Payment error: ${checkoutResult.error.message}`); + } + } else { + // Verify + const verifyResponse = await client.api.wallet["verify-topup"].$post({ + json: { cashfreeOrderId: orderData.orderId }, + }); + + if (!verifyResponse.ok) { + toast.info("Payment is being processed. Wallet will be credited shortly."); + } else { + toast.success(`$${amount} added successfully!`); + queryClient.invalidateQueries({ queryKey: ["billing-account"] }); + setTopupAmount(""); + } + } + setIsAddingCredits(false); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to initialize payment"; + toast.error(message); + setIsAddingCredits(false); + } + }, [isOrg, isPersonal, primaryOrganizationId, user, topupAmount, isScriptLoaded, billingAccountData, setupBilling, queryClient]); + // Fetch events for workspace breakdown const { data: eventsData, isLoading: eventsLoading } = useGetUsageEvents({ workspaceId: isOrg ? undefined : workspaceId, @@ -35,8 +203,23 @@ export const BillingDashboardClient = () => { ? organizations?.documents?.find((o: { $id: string }) => o.$id === primaryOrganizationId) : null; + // Fetch invoices + const { data: invoicesDoc, isLoading: isInvoicesLoading } = useGetInvoices({ + organizationId: isOrg ? primaryOrganizationId : undefined, + userId: isPersonal ? user?.$id : undefined, + limit: 10 + }); + const invoices = invoicesDoc?.documents || []; + + // Billing status + const { data: billingStatus } = useGetBillingStatus({ + organizationId: isOrg ? primaryOrganizationId : undefined, + userId: isPersonal ? user?.$id : undefined, + enabled: !!(isOrg ? primaryOrganizationId : user?.$id) + }); + return ( -
+
{/* Header */}
@@ -98,9 +281,138 @@ export const BillingDashboardClient = () => { + {/* Wallet & Credits - PREMIUM ENHANCED SECTION */} +
+ {/* Wallet Balance Display */} + +
+ + {/* Animated shapes */} +
+
+ + +
+ + + Fairlx Wallet + + + {isOrg ? "Organization Funds" : "Personal Credits"} + +
+ + Available balance for AI compute and automated tasks + +
+ + +
+
+ Total Available +
+ + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: walletCurrency, + minimumFractionDigits: 2, + }).format(walletBalance)} + + {walletCurrency} +
+ + {initialTrialAmount > 0 && ( +
+ {formatCurrency(realBalance)} real + + {formatCurrency(trialBalance)} trial + = + {formatCurrency(walletBalance)} total +
+ )} + + {trialExpiresAt && ( +
+ + Trial credit expires in {differenceInDays(new Date(trialExpiresAt), new Date())} days +
+ )} +
+ +
+
+
+ Real-time usage active +
+
+ * Balance updates instantly after compute operations +
+
+
+ + + + {/* Quick Top-up Card */} + + + + + Add Credits + + + +
+
+ $ + setTopupAmount(e.target.value)} + disabled={isAddingCredits} + /> +
+
+ {[5, 10, 25, 50].map((amt) => ( + + ))} +
+
+ + +

+ Secure payment via UPI, Cards & Net Banking +

+
+
+
+ {/* Usage Summary */}
- + Traffic Usage @@ -111,13 +423,13 @@ export const BillingDashboardClient = () => { {usageLoading ? "..." : `${usageSummary?.data?.trafficTotalGB?.toFixed(2) || 0} GB`}
- + This month
- + Storage Usage @@ -133,7 +445,7 @@ export const BillingDashboardClient = () => { - + Compute Units @@ -155,6 +467,128 @@ export const BillingDashboardClient = () => { workspaceId={workspaceId} organizationId={isOrg ? primaryOrganizationId : undefined} /> + + {/* Billing Lifecycle & Invoices */} +
+ {/* Billing Lifecycle */} + + + + + Billing Lifecycle + + Your current billing period and status + + +
+
+ +
+
+
+
+ Current Period + ACTIVE +
+

+ Started on {startOfMonth(new Date()).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} +

+
+
+ +
+
+
+
+ Next Invoice + UPCOMING +
+

+ Expected on {endOfMonth(new Date()).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} +

+
+
+
+ +
+
+

Account Status

+
+
+ {billingStatus?.status || "ACTIVE"} +
+
+ +
+ + + + {/* Invoice History */} + + +
+ + + Invoice History + + Recent billing statements +
+ +
+ +
+ {isInvoicesLoading ? ( +
+ + Fetching invoices... +
+ ) : invoices.length === 0 ? ( +
+ +

No invoices generated yet

+
+ ) : ( + invoices.map((invoice: BillingInvoice) => ( +
+
+
+ +
+
+

{invoice.invoiceId}

+

{format(new Date(invoice.$createdAt), "MMM dd, yyyy")}

+
+
+
+
+

+ {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(invoice.totalCost)} +

+ Paid +
+ +
+
+ )) + )} +
+
+
+
+ {/* Organization Workspaces Breakdown (ORG only) */} {isOrg && primaryOrganizationId && ( { {/* Upgrade CTA for Personal accounts */} {!isOrg && ( - - -
-
-

Upgrade to Organization

-

- Create unlimited workspaces, invite team members, and get organization-level billing. + +

+
+ + +
+
+

Scale to Organization

+

+ Collaborate with team members, manage multiple workspaces, and get unified billing for your entire team.

-
)} + {/* Scripts */} +