Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d6b8de2
feat(billing): unify upgrade routing with reason context + storage/ta…
waleedlatif1 Jun 22, 2026
8a1bbf4
fix(billing): re-arm limit-notification dedup on usage drops (prior-u…
waleedlatif1 Jun 22, 2026
3848e72
fix(billing): isolate per-admin email failures in org limit notificat…
waleedlatif1 Jun 22, 2026
f026df6
fix(billing): re-arm limit dedup at zero usage and zero prior usage (…
waleedlatif1 Jun 22, 2026
921ccbc
fix(billing): make storage-decrement notification re-arm only (never …
waleedlatif1 Jun 22, 2026
13bf0fe
fix(billing): resolve recipients before claiming so opt-outs don't bu…
waleedlatif1 Jun 22, 2026
9981a20
fix(billing): fire table limit emails on upsert inserts via shared no…
waleedlatif1 Jun 23, 2026
d915407
chore(billing): only log a limit email as sent when a recipient actua…
waleedlatif1 Jun 23, 2026
99177d9
chore(billing): match to_jsonb int cast between claim and re-arm for …
waleedlatif1 Jun 23, 2026
b99367d
fix(billing): notify table limits post-commit so a rolled-back insert…
waleedlatif1 Jun 23, 2026
d43e683
feat(pi): swap Pi Coding Agent icon to the pi glyph and use a black b…
waleedlatif1 Jun 23, 2026
c10f656
fix(billing): drop priorUsage re-arm to make dedup a single atomic cl…
waleedlatif1 Jun 23, 2026
a7ae72d
docs(billing): move limit-notification rationale to TSDoc, correct ta…
waleedlatif1 Jun 23, 2026
f467991
docs(db): note limit_notifications dedup is per-account, not per-table
waleedlatif1 Jun 23, 2026
375317b
perf(billing): cut redundant subscription fetches and edge-gate notif…
waleedlatif1 Jun 23, 2026
79a4d08
docs(billing): drop self-explanatory inline comments from the notific…
waleedlatif1 Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5318,6 +5318,18 @@ export function SmtpIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function PiIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 800' fill='currentColor'>
<path
fillRule='evenodd'
d='M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z'
/>
<path d='M517.36 400 H634.72 V634.72 H517.36 Z' />
</svg>
)
}

export function SshIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
Expand Down
12 changes: 10 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
Expand All @@ -25,6 +25,7 @@ import {
} from '@/components/emcn'
import { Download, Send } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { useLimitUpgradeToast } from '@/lib/billing/client'
import { captureEvent } from '@/lib/posthog/client'
import { triggerFileDownload } from '@/lib/uploads/client/download'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
Expand Down Expand Up @@ -197,6 +198,7 @@ export function Files() {
const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const uploadFile = useUploadWorkspaceFile()
const notifyLimit = useLimitUpgradeToast()
const deleteFile = useDeleteWorkspaceFile()
const renameFile = useRenameWorkspaceFile()
const createFolder = useCreateWorkspaceFileFolder()
Expand Down Expand Up @@ -699,6 +701,12 @@ export function Files() {
})
} catch (err) {
logger.error('Error uploading file:', err)
const message = getErrorMessage(err)
if (/storage limit/i.test(message)) {
notifyLimit('storage', message)
} else {
toast.error(`Failed to upload "${allowedFiles[i].name}"`)
}
}
}
} catch (err) {
Expand All @@ -708,7 +716,7 @@ export function Files() {
setUploadProgress({ completed: 0, total: 0, currentPercent: 0 })
}
},
[workspaceId, canEdit, currentFolderId]
[workspaceId, canEdit, currentFolderId, notifyLimit]
)

const rowDragDropConfig = useMemo<RowDragDropConfig>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Chip } from '@/components/emcn'
import { Credit } from '@/components/emcn/icons'
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
import { formatCredits } from '@/lib/billing/credits/conversion'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { useMyMemberCredits } from '@/hooks/queries/organization'
import { usePlanView } from '@/hooks/queries/plan-view'
Expand All @@ -33,7 +34,7 @@ function CreditsChipInner() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { data: memberCredits, isLoading: memberLoading } = useMyMemberCredits(workspaceId)

const upgradeHref = `/workspace/${workspaceId}/upgrade`
const upgradeHref = buildUpgradeHref(workspaceId, 'credits')

/**
* Warm the route bundle and the exact queries the Upgrade page gates on, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
hasPaidSubscriptionStatus,
hasUsableSubscriptionAccess,
} from '@/lib/billing/subscriptions/utils'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
Expand Down Expand Up @@ -125,7 +126,7 @@ export function Billing() {
const betterAuthSubscription = useSubscription()
const openBillingPortal = useOpenBillingPortal()

const upgradeHref = `/workspace/${workspaceId}/upgrade`
const upgradeHref = buildUpgradeHref(workspaceId)

/**
* Warm the Upgrade route bundle and the exact queries that page gates on, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
workspaceRoleLockReason,
} from '@/components/permissions'
import type { WorkspacePermission } from '@/lib/api/contracts/workspaces'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import {
MemberRow,
MemberSection,
Expand Down Expand Up @@ -105,7 +106,7 @@ export function Teammates() {
const inviteDisabledReason = activeWorkspace?.inviteDisabledReason ?? null
const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null

const upgradeHref = `/workspace/${workspaceId}/upgrade`
const upgradeHref = buildUpgradeHref(workspaceId, 'seats')

/**
* Warm the Upgrade route bundle and the queries it gates on, so a gated
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade'

Expand All @@ -9,5 +10,9 @@ export default async function UpgradePage({
params: Promise<{ workspaceId: string }>
}) {
const { workspaceId } = await params
return <Upgrade workspaceId={workspaceId} />
return (
<Suspense fallback={<div className='h-full bg-[var(--bg)]' />}>
<Upgrade workspaceId={workspaceId} />
</Suspense>
)
}
20 changes: 20 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/upgrade/search-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { parseAsStringLiteral } from 'nuqs/server'
import { UPGRADE_REASONS } from '@/lib/billing/upgrade-reasons'

/**
* Single source of truth for the upgrade page's `reason` query param.
*
* Nullable (no `.withDefault`): a clean URL means no reason and the page keeps
* its generic header. Shared by the client (`useQueryState`) and any server
* read via `createSearchParamsCache`.
*/
export const upgradeReasonParam = {
key: 'reason',
parser: parseAsStringLiteral(UPGRADE_REASONS),
} as const

/** Clean URLs, no back-stack churn — the reason is a passive header hint. */
export const upgradeUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
14 changes: 13 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { ArrowLeft, Chip, toast } from '@/components/emcn'
import {
getUpgradeCardCta,
Expand All @@ -11,6 +12,7 @@ import {
type UpgradeCardId,
} from '@/lib/billing/client'
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import {
BillingPeriodToggle,
Expand All @@ -26,6 +28,10 @@ import {
PRO_PLAN_CREDITS,
PRO_PLAN_FEATURES,
} from '@/app/workspace/[workspaceId]/upgrade/plan-configs'
import {
upgradeReasonParam,
upgradeUrlKeys,
} from '@/app/workspace/[workspaceId]/upgrade/search-params'
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'

const TYPEFORM_ENTERPRISE_URL = 'https://form.typeform.com/to/jqCO12pF' as const
Expand All @@ -47,8 +53,14 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
const state = useUpgradeState()
const router = useRouter()
const origin = useFullscreenOriginStore((s) => s.origin)
const [reason] = useQueryState(upgradeReasonParam.key, {
...upgradeReasonParam.parser,
...upgradeUrlKeys,
})
const [showAllFeatures, setShowAllFeatures] = useState(false)

const header = reason ? UPGRADE_REASON_COPY[reason].header : DEFAULT_UPGRADE_HEADER

const handleBack = useCallback(() => {
router.replace(origin ?? `/workspace/${workspaceId}/home`)
}, [origin, router, workspaceId])
Expand Down Expand Up @@ -152,7 +164,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
<div className='mx-auto flex w-full max-w-[960px] flex-col gap-7 pt-6 pb-3'>
<div className='flex flex-col items-center gap-4'>
<h1 className='text-balance text-center font-season text-[30px] text-[var(--text-primary)]'>
Plans that scale with you
{header}
</h1>
{state.showUpgradePlans && (
<BillingPeriodToggle isAnnual={state.isAnnual} onChange={state.setIsAnnual} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { ChipLink } from '@/components/emcn'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription'
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'

Expand All @@ -15,7 +16,7 @@ export function DeployUpgradeGate({ feature }: DeployUpgradeGateProps) {
const router = useRouter()
const queryClient = useQueryClient()
const { workspaceId } = useParams<{ workspaceId: string }>()
const upgradeHref = `/workspace/${workspaceId}/upgrade`
const upgradeHref = buildUpgradeHref(workspaceId)

// Warm the upgrade route + the queries it gates on so the click lands on
// cached data. ChipLink isn't memoized, so no useCallback is needed.
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/blocks/blocks/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
`,
category: 'blocks',
integrationType: IntegrationType.AI,
bgColor: '#6E56CF',
bgColor: '#000000',
icon: PiIcon,
subBlocks: [
{
Expand Down
1 change: 1 addition & 0 deletions apps/sim/components/emails/billing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { CreditPurchaseEmail } from './credit-purchase-email'
export { CreditsExhaustedEmail } from './credits-exhausted-email'
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
export { LimitThresholdEmail } from './limit-threshold-email'
export { PaymentFailedEmail } from './payment-failed-email'
export { PlanWelcomeEmail } from './plan-welcome-email'
export { UsageThresholdEmail } from './usage-threshold-email'
76 changes: 76 additions & 0 deletions apps/sim/components/emails/billing/limit-threshold-email.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBrandConfig } from '@/ee/whitelabeling'

interface LimitThresholdEmailProps {
/** `warning` = approaching the limit (~80%); `reached` = at/over the limit. */
kind: 'warning' | 'reached'
/** Limit category, drives the shared copy. */
reason: UpgradeReason
userName?: string
/** Pre-formatted current usage, e.g. "4.2 GB", "48,000 rows", "9 seats". */
usageLabel: string
/** Pre-formatted limit, e.g. "5 GB", "50,000 rows", "10 seats". */
limitLabel: string
percentUsed: number
upgradeLink: string
}

/**
* Single template for the per-category usage-limit emails (storage, tables,
* seats). Copy comes from {@link UPGRADE_REASON_COPY} so the email language
* matches the upgrade-page header the user lands on.
*/
export function LimitThresholdEmail({
kind,
reason,
userName,
usageLabel,
limitLabel,
percentUsed,
upgradeLink,
}: LimitThresholdEmailProps) {
const brand = getBrandConfig()
const copy = UPGRADE_REASON_COPY[reason]
const lead = kind === 'reached' ? copy.reachedLead : copy.warningLead
const previewText = `${brand.name}: ${lead}`

return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>

<Text style={baseStyles.paragraph}>
{lead} Upgrade your plan for more {copy.noun}.
</Text>

<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
{usageLabel} of {limitLabel} used ({percentUsed}%)
</Text>
</Section>

{/* Divider */}
<div style={baseStyles.divider} />

<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade</Text>
</Link>

{/* Divider */}
<div style={baseStyles.divider} />

<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
{kind === 'reached'
? 'One-time notification at 100% usage.'
: 'One-time notification at 80% usage.'}
</Text>
</EmailLayout>
)
}

export default LimitThresholdEmail
16 changes: 15 additions & 1 deletion apps/sim/components/emails/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
CreditsExhaustedEmail,
EnterpriseSubscriptionEmail,
FreeTierUpgradeEmail,
LimitThresholdEmail,
PaymentFailedEmail,
PlanWelcomeEmail,
UsageThresholdEmail,
Expand All @@ -24,9 +25,10 @@ import {
WorkspaceInvitationEmail,
} from '@/components/emails/invitations'
import { HelpConfirmationEmail } from '@/components/emails/support'
import type { UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBaseUrl } from '@/lib/core/utils/urls'

export { getEmailSubject } from './subjects'
export { getEmailSubject, getLimitEmailSubject } from './subjects'

interface WorkspaceInvitation {
workspaceId: string
Expand Down Expand Up @@ -153,6 +155,18 @@ export async function renderFreeTierUpgradeEmail(params: {
)
}

export async function renderLimitThresholdEmail(params: {
kind: 'warning' | 'reached'
reason: UpgradeReason
userName?: string
usageLabel: string
limitLabel: string
percentUsed: number
upgradeLink: string
}): Promise<string> {
return await render(LimitThresholdEmail(params))
}

export async function renderPlanWelcomeEmail(params: {
planName: string
userName?: string
Expand Down
13 changes: 13 additions & 0 deletions apps/sim/components/emails/subjects.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBrandConfig } from '@/ee/whitelabeling'

/** Email subject type for all supported email templates */
Expand Down Expand Up @@ -79,3 +80,15 @@ export function getEmailSubject(type: EmailSubjectType): string {
return brandName
}
}

/**
* Subject line for a per-category usage-limit email. Reuses the shared
* {@link UPGRADE_REASON_COPY} so the subject matches the email body and the
* upgrade-page header the user lands on.
*/
export function getLimitEmailSubject(reason: UpgradeReason, kind: 'warning' | 'reached'): string {
const brandName = getBrandConfig().name
const copy = UPGRADE_REASON_COPY[reason]
const subject = kind === 'reached' ? copy.reachedSubject : copy.warningSubject
return `${subject} on ${brandName}`
}
Loading
Loading