Skip to content

Commit 77976bc

Browse files
authored
feat(billing): unify upgrade routing with reason context + storage/tables limit emails (#5171)
* feat(billing): unify upgrade routing with reason context + storage/tables limit emails * fix(billing): re-arm limit-notification dedup on usage drops (prior-usage + decrement) * fix(billing): isolate per-admin email failures in org limit notifications * fix(billing): re-arm limit dedup at zero usage and zero prior usage (full clear / wipe-rebuild) * fix(billing): make storage-decrement notification re-arm only (never send on a shrink) * fix(billing): resolve recipients before claiming so opt-outs don't burn the dedup threshold * fix(billing): fire table limit emails on upsert inserts via shared notifyTableRowUsage * chore(billing): only log a limit email as sent when a recipient actually received it * chore(billing): match to_jsonb int cast between claim and re-arm for consistency * fix(billing): notify table limits post-commit so a rolled-back insert never emails or burns the claim * feat(pi): swap Pi Coding Agent icon to the pi glyph and use a black bgColor * fix(billing): drop priorUsage re-arm to make dedup a single atomic claim (no duplicate-email race) * docs(billing): move limit-notification rationale to TSDoc, correct tables warn-once behavior * docs(db): note limit_notifications dedup is per-account, not per-table * perf(billing): cut redundant subscription fetches and edge-gate notify to slash DB load * docs(billing): drop self-explanatory inline comments from the notification path
1 parent 8f312d2 commit 77976bc

38 files changed

Lines changed: 17966 additions & 85 deletions

File tree

apps/docs/components/icons.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5318,6 +5318,18 @@ export function SmtpIcon(props: SVGProps<SVGSVGElement>) {
53185318
)
53195319
}
53205320

5321+
export function PiIcon(props: SVGProps<SVGSVGElement>) {
5322+
return (
5323+
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 800' fill='currentColor'>
5324+
<path
5325+
fillRule='evenodd'
5326+
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'
5327+
/>
5328+
<path d='M517.36 400 H634.72 V634.72 H517.36 Z' />
5329+
</svg>
5330+
)
5331+
}
5332+
53215333
export function SshIcon(props: SVGProps<SVGSVGElement>) {
53225334
return (
53235335
<svg

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { toError } from '@sim/utils/errors'
5+
import { getErrorMessage, toError } from '@sim/utils/errors'
66
import { useParams, useRouter } from 'next/navigation'
77
import { useQueryStates } from 'nuqs'
88
import { usePostHog } from 'posthog-js/react'
@@ -25,6 +25,7 @@ import {
2525
} from '@/components/emcn'
2626
import { Download, Send } from '@/components/emcn/icons'
2727
import { getDocumentIcon } from '@/components/icons/document-icons'
28+
import { useLimitUpgradeToast } from '@/lib/billing/client'
2829
import { captureEvent } from '@/lib/posthog/client'
2930
import { triggerFileDownload } from '@/lib/uploads/client/download'
3031
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -197,6 +198,7 @@ export function Files() {
197198
const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId)
198199
const { data: members } = useWorkspaceMembersQuery(workspaceId)
199200
const uploadFile = useUploadWorkspaceFile()
201+
const notifyLimit = useLimitUpgradeToast()
200202
const deleteFile = useDeleteWorkspaceFile()
201203
const renameFile = useRenameWorkspaceFile()
202204
const createFolder = useCreateWorkspaceFileFolder()
@@ -699,6 +701,12 @@ export function Files() {
699701
})
700702
} catch (err) {
701703
logger.error('Error uploading file:', err)
704+
const message = getErrorMessage(err)
705+
if (/storage limit/i.test(message)) {
706+
notifyLimit('storage', message)
707+
} else {
708+
toast.error(`Failed to upload "${allowedFiles[i].name}"`)
709+
}
702710
}
703711
}
704712
} catch (err) {
@@ -708,7 +716,7 @@ export function Files() {
708716
setUploadProgress({ completed: 0, total: 0, currentPercent: 0 })
709717
}
710718
},
711-
[workspaceId, canEdit, currentFolderId]
719+
[workspaceId, canEdit, currentFolderId, notifyLimit]
712720
)
713721

714722
const rowDragDropConfig = useMemo<RowDragDropConfig>(

apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Chip } from '@/components/emcn'
77
import { Credit } from '@/components/emcn/icons'
88
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
99
import { formatCredits } from '@/lib/billing/credits/conversion'
10+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
1011
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
1112
import { useMyMemberCredits } from '@/hooks/queries/organization'
1213
import { usePlanView } from '@/hooks/queries/plan-view'
@@ -33,7 +34,7 @@ function CreditsChipInner() {
3334
const { workspaceId } = useParams<{ workspaceId: string }>()
3435
const { data: memberCredits, isLoading: memberLoading } = useMyMemberCredits(workspaceId)
3536

36-
const upgradeHref = `/workspace/${workspaceId}/upgrade`
37+
const upgradeHref = buildUpgradeHref(workspaceId, 'credits')
3738

3839
/**
3940
* Warm the route bundle and the exact queries the Upgrade page gates on, so

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
hasPaidSubscriptionStatus,
3434
hasUsableSubscriptionAccess,
3535
} from '@/lib/billing/subscriptions/utils'
36+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
3637
import { cn } from '@/lib/core/utils/cn'
3738
import { getBaseUrl } from '@/lib/core/utils/urls'
3839
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
@@ -125,7 +126,7 @@ export function Billing() {
125126
const betterAuthSubscription = useSubscription()
126127
const openBillingPortal = useOpenBillingPortal()
127128

128-
const upgradeHref = `/workspace/${workspaceId}/upgrade`
129+
const upgradeHref = buildUpgradeHref(workspaceId)
129130

130131
/**
131132
* Warm the Upgrade route bundle and the exact queries that page gates on, so

apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
workspaceRoleLockReason,
2525
} from '@/components/permissions'
2626
import type { WorkspacePermission } from '@/lib/api/contracts/workspaces'
27+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
2728
import {
2829
MemberRow,
2930
MemberSection,
@@ -105,7 +106,7 @@ export function Teammates() {
105106
const inviteDisabledReason = activeWorkspace?.inviteDisabledReason ?? null
106107
const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null
107108

108-
const upgradeHref = `/workspace/${workspaceId}/upgrade`
109+
const upgradeHref = buildUpgradeHref(workspaceId, 'seats')
109110

110111
/**
111112
* Warm the Upgrade route bundle and the queries it gates on, so a gated

apps/sim/app/workspace/[workspaceId]/upgrade/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense } from 'react'
12
import type { Metadata } from 'next'
23
import { Upgrade } from '@/app/workspace/[workspaceId]/upgrade/upgrade'
34

@@ -9,5 +10,9 @@ export default async function UpgradePage({
910
params: Promise<{ workspaceId: string }>
1011
}) {
1112
const { workspaceId } = await params
12-
return <Upgrade workspaceId={workspaceId} />
13+
return (
14+
<Suspense fallback={<div className='h-full bg-[var(--bg)]' />}>
15+
<Upgrade workspaceId={workspaceId} />
16+
</Suspense>
17+
)
1318
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { parseAsStringLiteral } from 'nuqs/server'
2+
import { UPGRADE_REASONS } from '@/lib/billing/upgrade-reasons'
3+
4+
/**
5+
* Single source of truth for the upgrade page's `reason` query param.
6+
*
7+
* Nullable (no `.withDefault`): a clean URL means no reason and the page keeps
8+
* its generic header. Shared by the client (`useQueryState`) and any server
9+
* read via `createSearchParamsCache`.
10+
*/
11+
export const upgradeReasonParam = {
12+
key: 'reason',
13+
parser: parseAsStringLiteral(UPGRADE_REASONS),
14+
} as const
15+
16+
/** Clean URLs, no back-stack churn — the reason is a passive header hint. */
17+
export const upgradeUrlKeys = {
18+
history: 'replace',
19+
clearOnDefault: true,
20+
} as const

apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useEffect, useState } from 'react'
44
import { getErrorMessage } from '@sim/utils/errors'
55
import { useRouter } from 'next/navigation'
6+
import { useQueryState } from 'nuqs'
67
import { ArrowLeft, Chip, toast } from '@/components/emcn'
78
import {
89
getUpgradeCardCta,
@@ -11,6 +12,7 @@ import {
1112
type UpgradeCardId,
1213
} from '@/lib/billing/client'
1314
import { ANNUAL_DISCOUNT_RATE } from '@/lib/billing/constants'
15+
import { DEFAULT_UPGRADE_HEADER, UPGRADE_REASON_COPY } from '@/lib/billing/upgrade-reasons'
1416
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
1517
import {
1618
BillingPeriodToggle,
@@ -26,6 +28,10 @@ import {
2628
PRO_PLAN_CREDITS,
2729
PRO_PLAN_FEATURES,
2830
} from '@/app/workspace/[workspaceId]/upgrade/plan-configs'
31+
import {
32+
upgradeReasonParam,
33+
upgradeUrlKeys,
34+
} from '@/app/workspace/[workspaceId]/upgrade/search-params'
2935
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
3036

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

62+
const header = reason ? UPGRADE_REASON_COPY[reason].header : DEFAULT_UPGRADE_HEADER
63+
5264
const handleBack = useCallback(() => {
5365
router.replace(origin ?? `/workspace/${workspaceId}/home`)
5466
}, [origin, router, workspaceId])
@@ -152,7 +164,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
152164
<div className='mx-auto flex w-full max-w-[960px] flex-col gap-7 pt-6 pb-3'>
153165
<div className='flex flex-col items-center gap-4'>
154166
<h1 className='text-balance text-center font-season text-[30px] text-[var(--text-primary)]'>
155-
Plans that scale with you
167+
{header}
156168
</h1>
157169
{state.showUpgradePlans && (
158170
<BillingPeriodToggle isAnnual={state.isAnnual} onChange={state.setIsAnnual} />

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
44
import { ArrowRight } from 'lucide-react'
55
import { useParams, useRouter } from 'next/navigation'
66
import { ChipLink } from '@/components/emcn'
7+
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
78
import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription'
89
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
910

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

2021
// Warm the upgrade route + the queries it gates on so the click lands on
2122
// cached data. ChipLink isn't memoized, so no useCallback is needed.

apps/sim/blocks/blocks/pi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const PiBlock: BlockConfig<PiResponse> = {
5353
`,
5454
category: 'blocks',
5555
integrationType: IntegrationType.AI,
56-
bgColor: '#6E56CF',
56+
bgColor: '#000000',
5757
icon: PiIcon,
5858
subBlocks: [
5959
{

0 commit comments

Comments
 (0)