From 7f1ff7fd86cf37b693fe734cbff305c06b4f62e0 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 25 Oct 2025 09:59:57 -1000 Subject: [PATCH 1/2] fix(billing): should allow restoring subscription (#1728) * fix(already-cancelled-sub): UI should allow restoring subscription * restore functionality fixed * fix --- apps/sim/app/api/billing/portal/route.ts | 7 +- .../cancel-subscription.tsx | 106 +++++++++--------- .../components/subscription/subscription.tsx | 1 + apps/sim/lib/billing/core/billing.ts | 12 ++ apps/sim/stores/subscription/types.ts | 1 + 5 files changed, 70 insertions(+), 57 deletions(-) diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts index 017fbb8bd7..959a83cd7f 100644 --- a/apps/sim/app/api/billing/portal/route.ts +++ b/apps/sim/app/api/billing/portal/route.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { subscription as subscriptionTable, user } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, eq, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { requireStripeClient } from '@/lib/billing/stripe-client' @@ -38,7 +38,10 @@ export async function POST(request: NextRequest) { .where( and( eq(subscriptionTable.referenceId, organizationId), - eq(subscriptionTable.status, 'active') + or( + eq(subscriptionTable.status, 'active'), + eq(subscriptionTable.cancelAtPeriodEnd, true) + ) ) ) .limit(1) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx index 1f5ea569aa..fd81cec55e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx @@ -12,7 +12,6 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useSession, useSubscription } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' @@ -30,6 +29,7 @@ interface CancelSubscriptionProps { } subscriptionData?: { periodEnd?: Date | null + cancelAtPeriodEnd?: boolean } } @@ -127,35 +127,48 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const subscriptionStatus = getSubscriptionStatus() const activeOrgId = activeOrganization?.id - // For team/enterprise plans, get the subscription ID from organization store - if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) { - const orgSubscription = useOrganizationStore.getState().subscriptionData + if (isCancelAtPeriodEnd) { + if (!betterAuthSubscription.restore) { + throw new Error('Subscription restore not available') + } + + let referenceId: string + let subscriptionId: string | undefined + + if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) { + const orgSubscription = useOrganizationStore.getState().subscriptionData + referenceId = activeOrgId + subscriptionId = orgSubscription?.id + } else { + // For personal subscriptions, use user ID and let better-auth find the subscription + referenceId = session.user.id + subscriptionId = undefined + } + + logger.info('Restoring subscription', { referenceId, subscriptionId }) - if (orgSubscription?.id && orgSubscription?.cancelAtPeriodEnd) { - // Restore the organization subscription - if (!betterAuthSubscription.restore) { - throw new Error('Subscription restore not available') - } - - const result = await betterAuthSubscription.restore({ - referenceId: activeOrgId, - subscriptionId: orgSubscription.id, - }) - logger.info('Organization subscription restored successfully', result) + // Build restore params - only include subscriptionId if we have one (team/enterprise) + const restoreParams: any = { referenceId } + if (subscriptionId) { + restoreParams.subscriptionId = subscriptionId } + + const result = await betterAuthSubscription.restore(restoreParams) + + logger.info('Subscription restored successfully', result) } - // Refresh state and close await refresh() if (activeOrgId) { await loadOrganizationSubscription(activeOrgId) await refreshOrganization().catch(() => {}) } + setIsDialogOpen(false) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to keep subscription' + const errorMessage = error instanceof Error ? error.message : 'Failed to restore subscription' setError(errorMessage) - logger.error('Failed to keep subscription', { error }) + logger.error('Failed to restore subscription', { error }) } finally { setIsLoading(false) } @@ -190,19 +203,15 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const periodEndDate = getPeriodEndDate() // Check if subscription is set to cancel at period end - const isCancelAtPeriodEnd = (() => { - const subscriptionStatus = getSubscriptionStatus() - if (subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) { - return useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd === true - } - return false - })() + const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true return ( <>
- Manage Subscription + + {isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'} + {isCancelAtPeriodEnd && (

You'll keep access until {formatDate(periodEndDate)} @@ -217,10 +226,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub 'h-8 rounded-[8px] font-medium text-xs transition-all duration-200', error ? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500' - : 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500' + : isCancelAtPeriodEnd + ? 'text-muted-foreground hover:border-green-500 hover:bg-green-500 hover:text-white dark:hover:border-green-500 dark:hover:bg-green-500' + : 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500' )} > - {error ? 'Error' : 'Manage'} + {error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'}

@@ -228,11 +239,11 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub - {isCancelAtPeriodEnd ? 'Manage' : 'Cancel'} {subscription.plan} subscription? + {isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} subscription? {isCancelAtPeriodEnd - ? 'Your subscription is set to cancel at the end of the billing period. You can reactivate it or manage other settings.' + ? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?' : `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate( periodEndDate )}, then downgrade to free plan.`}{' '} @@ -260,38 +271,23 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub setIsDialogOpen(false) : handleKeep} disabled={isLoading} > - Keep Subscription + {isCancelAtPeriodEnd ? 'Cancel' : 'Keep Subscription'} {(() => { const subscriptionStatus = getSubscriptionStatus() - if ( - subscriptionStatus.isPaid && - (activeOrganization?.id - ? useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd - : false) - ) { + if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) { return ( - - - -
- - Continue - -
-
- -

Subscription will be cancelled at end of billing period

-
-
-
+ + {isLoading ? 'Restoring...' : 'Restore Subscription'} + ) } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index 9ac78581ef..b69b499aff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -523,6 +523,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { }} subscriptionData={{ periodEnd: subscriptionData?.periodEnd || null, + cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd, }} />
diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 48085eb821..55b6a207f7 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -220,6 +220,7 @@ export async function getSimplifiedBillingSummary( metadata: any stripeSubscriptionId: string | null periodEnd: Date | string | null + cancelAtPeriodEnd?: boolean // Usage details usage: { current: number @@ -318,6 +319,7 @@ export async function getSimplifiedBillingSummary( metadata: subscription.metadata || null, stripeSubscriptionId: subscription.stripeSubscriptionId || null, periodEnd: subscription.periodEnd || null, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined, // Usage details usage: { current: usageData.currentUsage, @@ -393,6 +395,7 @@ export async function getSimplifiedBillingSummary( metadata: subscription?.metadata || null, stripeSubscriptionId: subscription?.stripeSubscriptionId || null, periodEnd: subscription?.periodEnd || null, + cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || undefined, // Usage details usage: { current: currentUsage, @@ -450,5 +453,14 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { lastPeriodCost: 0, daysRemaining: 0, }, + ...(type === 'organization' && { + organizationData: { + seatCount: 0, + memberCount: 0, + totalBasePrice: 0, + totalCurrentUsage: 0, + totalOverage: 0, + }, + }), } } diff --git a/apps/sim/stores/subscription/types.ts b/apps/sim/stores/subscription/types.ts index c0de147d45..643694b795 100644 --- a/apps/sim/stores/subscription/types.ts +++ b/apps/sim/stores/subscription/types.ts @@ -29,6 +29,7 @@ export interface SubscriptionData { metadata: any | null stripeSubscriptionId: string | null periodEnd: Date | null + cancelAtPeriodEnd?: boolean usage: UsageData billingBlocked?: boolean } From 69073a37f550ce46794a51304c868d29a8b21456 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Thu, 30 Oct 2025 23:00:28 +0000 Subject: [PATCH 2/2] - Replace `await import` with `require` for improved compatibility in local storage operations. - Restrict local file uploads, downloads, and deletions to server-side execution only. - Update webpack configuration to prevent bundling server-only modules in the client. - Extend dependencies with necessary libraries (e.g., `chalk`, `lodash`, `nanoid`, etc.). - Adjust scripts, packages, and `serverExternalPackages` in project configuration. - Optimize runtime setup for server-side uploads directory. --- apps/sim/lib/uploads/core/setup.server.ts | 23 +++++++++++---- apps/sim/lib/uploads/core/storage-client.ts | 32 ++++++++++++++------- apps/sim/next.config.ts | 26 ++++++++++++++++- apps/sim/package.json | 13 +++++++-- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/uploads/core/setup.server.ts b/apps/sim/lib/uploads/core/setup.server.ts index aef5f08a0c..ffa26ab980 100644 --- a/apps/sim/lib/uploads/core/setup.server.ts +++ b/apps/sim/lib/uploads/core/setup.server.ts @@ -1,6 +1,3 @@ -import { existsSync } from 'fs' -import { mkdir } from 'fs/promises' -import path, { join } from 'path' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { getStorageProvider, USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/core/setup' @@ -8,8 +5,16 @@ import { getStorageProvider, USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uplo const logger = createLogger('UploadsSetup') // Server-only upload directory path -const PROJECT_ROOT = path.resolve(process.cwd()) -export const UPLOAD_DIR_SERVER = join(PROJECT_ROOT, 'uploads') +// Use dynamic import for path to avoid bundling issues +let UPLOAD_DIR_SERVER: string | undefined + +if (typeof window === 'undefined') { + const path = require('path') + const PROJECT_ROOT = path.resolve(process.cwd()) + UPLOAD_DIR_SERVER = path.join(PROJECT_ROOT, 'uploads') +} + +export { UPLOAD_DIR_SERVER } /** * Server-only function to ensure uploads directory exists @@ -25,7 +30,15 @@ export async function ensureUploadsDirectory() { return true } + if (typeof window !== 'undefined' || !UPLOAD_DIR_SERVER) { + // Skip on client side + return true + } + try { + const { existsSync } = require('fs') + const { mkdir } = require('fs/promises') + if (!existsSync(UPLOAD_DIR_SERVER)) { await mkdir(UPLOAD_DIR_SERVER, { recursive: true }) } else { diff --git a/apps/sim/lib/uploads/core/storage-client.ts b/apps/sim/lib/uploads/core/storage-client.ts index 5b48d7a5f6..e13d14b655 100644 --- a/apps/sim/lib/uploads/core/storage-client.ts +++ b/apps/sim/lib/uploads/core/storage-client.ts @@ -92,11 +92,15 @@ export async function uploadFile( return uploadToS3(file, fileName, contentType, configOrSize) } + if (typeof window !== 'undefined') { + throw new Error('Local file upload is only supported on the server') + } + logger.info(`Uploading file to local storage: ${fileName}`) - const { writeFile } = await import('fs/promises') - const { join } = await import('path') - const { v4: uuidv4 } = await import('uuid') - const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server') + const { writeFile } = require('fs/promises') + const { join } = require('path') + const { v4: uuidv4 } = require('uuid') + const { UPLOAD_DIR_SERVER } = require('@/lib/uploads/core/setup.server') const safeFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_').replace(/\.\./g, '') const uniqueKey = `${uuidv4()}-${safeFileName}` @@ -169,10 +173,14 @@ export async function downloadFile( return downloadFromS3(key) } + if (typeof window !== 'undefined') { + throw new Error('Local file download is only supported on the server') + } + logger.info(`Downloading file from local storage: ${key}`) - const { readFile } = await import('fs/promises') - const { join, resolve, sep } = await import('path') - const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server') + const { readFile } = require('fs/promises') + const { join, resolve, sep } = require('path') + const { UPLOAD_DIR_SERVER } = require('@/lib/uploads/core/setup.server') const safeKey = key.replace(/\.\./g, '').replace(/[/\\]/g, '') const filePath = join(UPLOAD_DIR_SERVER, safeKey) @@ -210,10 +218,14 @@ export async function deleteFile(key: string): Promise { return deleteFromS3(key) } + if (typeof window !== 'undefined') { + throw new Error('Local file deletion is only supported on the server') + } + logger.info(`Deleting file from local storage: ${key}`) - const { unlink } = await import('fs/promises') - const { join, resolve, sep } = await import('path') - const { UPLOAD_DIR_SERVER } = await import('@/lib/uploads/core/setup.server') + const { unlink } = require('fs/promises') + const { join, resolve, sep } = require('path') + const { UPLOAD_DIR_SERVER } = require('@/lib/uploads/core/setup.server') const safeKey = key.replace(/\.\./g, '').replace(/[/\\]/g, '') const filePath = join(UPLOAD_DIR_SERVER, safeKey) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 180c8cf2ef..2b36ef98ec 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -4,6 +4,30 @@ import { isDev, isHosted } from './lib/environment' import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/security/csp' const nextConfig: NextConfig = { + webpack: (config, { isServer }) => { + if (!isServer) { + // Don't resolve server-only modules on the client + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + 'fs/promises': false, + path: false, + dns: false, + util: false, + net: false, + tls: false, + crypto: false, + stream: false, + perf_hooks: false, + os: false, + http: false, + https: false, + zlib: false, + child_process: false, + } + } + return config + }, devIndicators: false, images: { remotePatterns: [ @@ -75,7 +99,7 @@ const nextConfig: NextConfig = { turbopack: { resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.json'], }, - serverExternalPackages: ['pdf-parse'], + serverExternalPackages: ['pdf-parse', '@azure/storage-blob', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'], experimental: { optimizeCss: true, turbopackSourceMaps: false, diff --git a/apps/sim/package.json b/apps/sim/package.json index 68873f2c92..2d6bd97e18 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -8,7 +8,7 @@ "node": ">=20.0.0" }, "scripts": { - "dev": "next dev --turbo --port 3000", + "dev": "next dev --port 3000", "dev:classic": "next dev", "dev:sockets": "bun run socket-server/index.ts", "dev:full": "concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"", @@ -39,6 +39,8 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-node": "^0.200.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "@opentelemetry/sdk-trace-node": "2.2.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "1.1.10", @@ -59,12 +61,14 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-visually-hidden": "1.2.3", "@react-email/components": "^0.0.34", "@trigger.dev/sdk": "4.0.4", "@types/pdf-parse": "1.1.5", "@types/three": "0.177.0", "better-auth": "1.3.12", "browser-image-compression": "^2.0.2", + "chalk": "^5.3.0", "cheerio": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -85,9 +89,11 @@ "js-tiktoken": "1.0.21", "js-yaml": "4.1.0", "jwt-decode": "^4.0.0", + "lodash": "4.17.21", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", "mysql2": "3.14.3", + "nanoid": "5.1.6", "next": "^15.4.1", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", @@ -108,6 +114,7 @@ "reactflow": "^11.11.4", "remark-gfm": "4.0.1", "resend": "^4.1.2", + "server-only": "0.0.1", "sharp": "0.34.3", "socket.io": "^4.8.1", "stripe": "18.5.0", @@ -116,9 +123,11 @@ "three": "0.177.0", "uuid": "^11.1.0", "xlsx": "0.18.5", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zustand": "^4.5.5" }, "devDependencies": { + "@playwright/test": "1.56.1", "@testing-library/jest-dom": "^6.6.3", "@trigger.dev/build": "4.0.4", "@types/html-to-text": "9.0.4",