From 3f6142ecd6cbdc5099bc3cb648f846cfd0487fcb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 17:45:43 -0700 Subject: [PATCH 01/11] fix(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route every billing decision (usage limits, credits, storage, rate limit, threshold billing, webhooks, UI permissions) through the subscription's `referenceId` instead of plan-name heuristics. Fixes the production state where a `pro_6000` subscription attached to an organization was treated as personal Pro by display/edit code while execution correctly enforced the org cap. Scope - Add `isOrgScopedSubscription(sub, userId)` (pure) and `isSubscriptionOrgScoped(sub)` (async DB-backed) helpers. One is used wherever a user perspective is available; the other in webhook handlers that only have a subscription row. - Replace plan-name scope checks in ~20 files: usage/limit readers, credits balance + purchase, threshold billing, storage limits + tracking, rate limiter, invoice + subscription webhooks, seat management, membership join/leave, `switch-plan` admin gate, admin credits/billing routes, copilot 402 handler, UI subscription settings + permissions + sidebar indicator, React Query types. Plan sync - Add `syncSubscriptionPlan(subscriptionId, currentPlan, planFromStripe)` called from `onSubscriptionComplete` and `onSubscriptionUpdate` so the DB `plan` column heals on every Stripe event. Pro->Team upgrades previously updated price, seats, and referenceId but left `plan` stale — this is what produced the `pro_6000`-on-org row. Priority + grace period - `getHighestPrioritySubscription` now prefers org over personal within each tier (Enterprise > Team > Pro, org > personal at each). A user with a `cancelAtPeriodEnd` personal Pro who joins a paid org routes pooled resources to the org through the grace window. - `calculateSubscriptionOverage` personal-Pro branch reads user_stats directly (bypassing priority) and bills only `proPeriodCostSnapshot` when the user joined a paid org mid-cycle, so post-join org usage isn't double-charged on the personal Pro's final invoice. `resetUsageForSubscription` mirrors this: preserves `currentPeriodCost` / `currentPeriodCopilotCost` when `proPeriodCostSnapshot > 0` so the org's next cycle-close captures post-join usage correctly. Uniform base-price formula - `basePrice × (seats ?? 1)` everywhere: `getOrgUsageLimit`, `updateOrganizationUsageLimit`, `setUsageLimitForCredits`, `calculateSubscriptionOverage`, threshold billing, `syncSubscriptionUsageLimits`, `getOrganizationBillingData`. Admin dashboard math now agrees with enforcement math. Storage transfer on join - Invitation-accept flow moves `user_stats.storageUsedBytes` into `organization.storageUsedBytes` inside the same transaction when the org is paid. - `syncSubscriptionUsageLimits` runs a bulk-backfill version so members who joined before this fix, or orgs that upgraded from free to paid after members joined, get pulled into the org pool on the next subscription event. Idempotent. UX polish - Copilot 402 handler differentiates personal-scoped ("increase your usage limit") from org-scoped ("ask an owner or admin to raise the limit") while keeping the `increase_limit` action code the parser already understands. - Duplicate-subscription error on team upgrade names the existing plan via `getDisplayPlanName`. - Invitation-accept invalidates subscription + organization React Query caches before redirect so settings doesn't flash the user's pre-join personal view. Dead code removal - Remove unused `calculateUserOverage`, and the following fields on `SubscriptionBillingData` / `getSimplifiedBillingSummary` that no consumer in the monorepo read: `basePrice`, `overageAmount`, `totalProjected`, `tierCredits`, `basePriceCredits`, `currentUsageCredits`, `overageAmountCredits`, `totalProjectedCredits`, `usageLimitCredits`, `currentCredits`, `limitCredits`, `lastPeriodCostCredits`, `lastPeriodCopilotCostCredits`, `copilotCostCredits`, and the `organizationData` subobject. Add `metadata: unknown` to match what the server returns. Notes for the triggering customer - The `pro_6000`-on-org row self-heals on the next Stripe event via `syncSubscriptionPlan`. For the one known customer, a direct UPDATE is sufficient: `UPDATE subscription SET plan='team_6000' WHERE id='aq2...' AND plan='pro_6000'`. Made-with: Cursor --- apps/sim/app/api/billing/route.ts | 10 - apps/sim/app/api/billing/switch-plan/route.ts | 8 +- .../[id]/invitations/[invitationId]/route.ts | 44 +- apps/sim/app/api/v1/admin/credits/route.ts | 8 +- .../api/v1/admin/users/[id]/billing/route.ts | 11 +- apps/sim/app/invite/[id]/invite.tsx | 14 + .../subscription/subscription-permissions.ts | 45 +- .../components/subscription/subscription.tsx | 71 ++- .../usage-indicator/usage-indicator.tsx | 33 +- apps/sim/hooks/queries/organization.ts | 18 +- apps/sim/hooks/queries/subscription.ts | 34 +- apps/sim/lib/auth/auth.ts | 14 +- apps/sim/lib/billing/authorization.ts | 20 +- .../lib/billing/calculations/usage-monitor.ts | 315 ++++++++----- apps/sim/lib/billing/client/upgrade.ts | 20 +- apps/sim/lib/billing/core/billing.ts | 435 +++++++----------- apps/sim/lib/billing/core/organization.ts | 26 +- apps/sim/lib/billing/core/plan.ts | 24 +- apps/sim/lib/billing/core/subscription.ts | 31 ++ apps/sim/lib/billing/core/usage.ts | 277 ++++++----- apps/sim/lib/billing/credits/balance.ts | 18 +- apps/sim/lib/billing/credits/purchase.ts | 16 +- apps/sim/lib/billing/organization.ts | 70 ++- .../lib/billing/organizations/membership.ts | 12 +- apps/sim/lib/billing/plan-helpers.ts | 18 + apps/sim/lib/billing/storage/limits.ts | 36 +- apps/sim/lib/billing/storage/tracking.ts | 12 +- apps/sim/lib/billing/subscriptions/utils.ts | 39 +- apps/sim/lib/billing/threshold-billing.ts | 26 +- apps/sim/lib/billing/types/index.ts | 9 + .../lib/billing/validation/seat-management.ts | 9 +- apps/sim/lib/billing/webhooks/invoices.ts | 121 +++-- apps/sim/lib/billing/webhooks/subscription.ts | 10 +- apps/sim/lib/copilot/request/tools/billing.ts | 20 +- .../sim/lib/core/rate-limiter/rate-limiter.ts | 8 +- apps/sim/lib/logs/execution/logger.ts | 10 +- 36 files changed, 1152 insertions(+), 740 deletions(-) diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index 3fbae3c1df1..559e4f76df4 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -7,8 +7,6 @@ import { getSession } from '@/lib/auth' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' import { getOrganizationBillingData } from '@/lib/billing/core/organization' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { getPlanTierCredits } from '@/lib/billing/plan-helpers' const logger = createLogger('UnifiedBillingAPI') @@ -107,7 +105,6 @@ export async function GET(request: NextRequest) { ) } - // Transform data to match component expectations billingData = { organizationId: rawBillingData.organizationId, organizationName: rawBillingData.organizationName, @@ -122,17 +119,10 @@ export async function GET(request: NextRequest) { averageUsagePerMember: rawBillingData.averageUsagePerMember, billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null, billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null, - tierCredits: getPlanTierCredits(rawBillingData.subscriptionPlan), - totalCurrentUsageCredits: dollarsToCredits(rawBillingData.totalCurrentUsage), - totalUsageLimitCredits: dollarsToCredits(rawBillingData.totalUsageLimit), - minimumBillingAmountCredits: dollarsToCredits(rawBillingData.minimumBillingAmount), - averageUsagePerMemberCredits: dollarsToCredits(rawBillingData.averageUsagePerMember), members: rawBillingData.members.map((m) => ({ ...m, joinedAt: m.joinedAt.toISOString(), lastActive: m.lastActive?.toISOString() || null, - currentUsageCredits: dollarsToCredits(m.currentUsage), - usageLimitCredits: dollarsToCredits(m.usageLimit), })), } diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index cdc1ca5e65c..e68911ad29a 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -9,12 +9,13 @@ import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { writeBillingInterval } from '@/lib/billing/core/subscription' -import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers' +import { getPlanType, isEnterprise } from '@/lib/billing/plan-helpers' import { getPlanByName } from '@/lib/billing/plans' import { requireStripeClient } from '@/lib/billing/stripe-client' import { hasUsableSubscriptionAccess, hasUsableSubscriptionStatus, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { toError } from '@/lib/core/utils/helpers' @@ -93,7 +94,10 @@ export async function POST(request: NextRequest) { ) } - if (isOrgPlan(sub.plan)) { + // Any subscription whose referenceId is an organization (team, + // enterprise, or `pro_*` attached to an org) requires org admin/owner + // to change the plan. + if (isOrgScopedSubscription(sub, userId)) { const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId) if (!hasPermission) { return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index 7f4f7d8004c..bb8941644d0 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -14,7 +14,7 @@ import { workspaceInvitation, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getEmailSubject, renderInvitationEmail } from '@/components/emails' @@ -22,7 +22,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' -import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers' +import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -356,7 +356,10 @@ export async function PUT( .limit(1) const orgSub = orgSubs[0] - const orgIsPaid = orgSub && isOrgPlan(orgSub.plan) + // Any paid subscription attached to the org triggers the + // "snapshot my personal Pro usage and cancel it" flow — includes + // `pro_*` plans transferred to the org, not just team/enterprise. + const orgIsPaid = orgSub && isPaid(orgSub.plan) if (orgIsPaid) { const userId = session.user.id @@ -410,6 +413,41 @@ export async function PUT( personalProToCancel = personalPro } } + + // Transfer the joining user's accumulated personal storage + // bytes into the organization's pool. After this point + // `isOrgScopedSubscription` returns true for the user, so + // `getUserStorageUsage`/`incrementStorageUsage`/`decrementStorageUsage` + // all route through `organization.storageUsedBytes`. Without + // this transfer, pre-join bytes would be orphaned on the + // user's row and subsequent decrements (deleting a pre-join + // file after joining) would wrongly reduce the org pool. + const storageRows = await tx + .select({ storageUsedBytes: userStats.storageUsedBytes }) + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) + + const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0 + if (bytesToTransfer > 0) { + await tx + .update(organization) + .set({ + storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`, + }) + .where(eq(organization.id, organizationId)) + + await tx + .update(userStats) + .set({ storageUsedBytes: 0 }) + .where(eq(userStats.userId, userId)) + + logger.info('Transferred personal storage bytes to org pool on join', { + userId, + organizationId, + bytes: bytesToTransfer, + }) + } } } catch (error) { logger.error('Failed to handle Pro user joining team', { diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index feaec3b95d9..2ed84a4e2ce 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -30,10 +30,11 @@ import { and, eq, inArray } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { addCredits } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' -import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers' +import { isPaid } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, getEffectiveSeats, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' import { generateShortId } from '@/lib/core/utils/uuid' import { withAdminAuth } from '@/app/api/v1/admin/middleware' @@ -110,7 +111,10 @@ export const POST = withAdminAuth(async (request) => { const plan = userSubscription.plan let seats: number | null = null - if (isOrgPlan(plan)) { + // Route admin credits to the subscription's actual entity. Any sub whose + // `referenceId` is an org gets credited to the org pool — including + // `pro_*` plans transferred to an org. + if (isOrgScopedSubscription(userSubscription, resolvedUserId)) { entityType = 'organization' entityId = userSubscription.referenceId diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 1639db0baea..2130036679a 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -23,7 +23,7 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch import { createLogger } from '@sim/logger' import { eq, or } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { generateShortId } from '@/lib/core/utils/uuid' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -155,7 +155,10 @@ export const PATCH = withAdminAuthParams(async (request, context) = .limit(1) const userSubscription = await getHighestPrioritySubscription(userId) - const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan) + // True for any user whose effective subscription is attached to an org + // (team, enterprise, or `pro_*` transferred to an org). They have no + // individual usage limit — the org cap governs. + const isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId) const [orgMembership] = await db .select({ organizationId: member.organizationId }) @@ -168,9 +171,9 @@ export const PATCH = withAdminAuthParams(async (request, context) = const warnings: string[] = [] if (body.currentUsageLimit !== undefined) { - if (isTeamOrEnterpriseMember && orgMembership) { + if (isOrgScopedMember && orgMembership) { warnings.push( - 'User is a team/enterprise member. Individual limits may be ignored in favor of organization limits.' + 'User is on an org-scoped subscription. Individual limits are ignored in favor of organization limits.' ) } diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 10658d8f7f0..ddf458a7dc6 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -2,9 +2,12 @@ import { useEffect, useState } from 'react' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { client, useSession } from '@/lib/auth/auth-client' import { InviteLayout, InviteStatusCard } from '@/app/invite/components' +import { organizationKeys } from '@/hooks/queries/organization' +import { subscriptionKeys } from '@/hooks/queries/subscription' const logger = createLogger('InviteById') @@ -166,6 +169,7 @@ export default function Invite() { const inviteId = params.id as string const searchParams = useSearchParams() const { data: session, isPending } = useSession() + const queryClient = useQueryClient() const [invitationDetails, setInvitationDetails] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -345,6 +349,16 @@ export default function Invite() { organizationId: orgId, }) + // Invalidate billing / org caches so `/workspace` doesn't flash the + // user's pre-join personal subscription while the new team-scoped + // data is being refetched. Accept-flow side effects (snapshot, + // storage transfer, plan sync, member insert) have already + // committed by the time we reach here. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }), + queryClient.invalidateQueries({ queryKey: organizationKeys.all }), + ]) + setAccepted(true) setTimeout(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts index 9d901c8e7b9..0e9e861eba6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts @@ -17,6 +17,12 @@ export interface SubscriptionState { isTeam: boolean isEnterprise: boolean isPaid: boolean + /** + * True when the subscription's `referenceId` is an organization. Source + * of truth for scope-based decisions — `pro_*` plans that have been + * transferred to an org are org-scoped even though `isTeam` is false. + */ + isOrgScoped: boolean plan: string status: string } @@ -30,21 +36,30 @@ export function getSubscriptionPermissions( subscription: SubscriptionState, userRole: UserRole ): SubscriptionPermissions { - const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription + const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription const { isTeamAdmin } = userRole + // "Org-scoped non-admin" collapses all the "team member" behaviors + // (hidden edit, hidden cancel, no upgrade plans, pooled view, etc.). + // This includes members of `pro_*` orgs that aren't admins/owners. + const orgMemberOnly = isOrgScoped && !isTeamAdmin + const orgAdminOrSolo = !isOrgScoped || isTeamAdmin + const isEnterpriseMember = isEnterprise && !isTeamAdmin const canViewUsageInfo = !isEnterpriseMember return { canUpgradeToPro: isFree, - canUpgradeToTeam: isFree || (isPro && !isTeam), - canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members - canManageTeam: isTeam && isTeamAdmin, - canEditUsageLimit: (isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users and team admins see pencil - canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel - showTeamMemberView: isTeam && !isTeamAdmin, - showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans + canUpgradeToTeam: isFree || (isPro && !isOrgScoped), + canViewEnterprise: !isEnterprise && !orgMemberOnly, + canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise, + // Edit the limit when: paid plan (not free, not enterprise) AND either + // personally-scoped or acting as an org admin/owner. + canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo, + canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo, + showTeamMemberView: orgMemberOnly, + showUpgradePlans: + (isFree || (isPro && !isOrgScoped) || (isTeam && isTeamAdmin)) && !isEnterprise, isEnterpriseMember, canViewUsageInfo, } @@ -55,22 +70,24 @@ export function getVisiblePlans( userRole: UserRole ): ('pro' | 'team' | 'enterprise')[] { const plans: ('pro' | 'team' | 'enterprise')[] = [] - const { isFree, isPro, isTeam } = subscription + const { isFree, isPro, isTeam, isOrgScoped } = subscription const { isTeamAdmin } = userRole // Free users see all plans if (isFree) { plans.push('pro', 'team', 'enterprise') } - // Pro users see team and enterprise - else if (isPro && !isTeam) { + // Personally-scoped Pro: can upgrade to team or enterprise + else if (isPro && !isOrgScoped) { plans.push('team', 'enterprise') } - // Team owners see only enterprise (no team plan since they already have it) - else if (isTeam && isTeamAdmin) { + // Team/org owners/admins: only enterprise (already on a team-level plan) + else if (isOrgScoped && isTeamAdmin && !isTeam) { + plans.push('enterprise') + } else if (isTeam && isTeamAdmin) { plans.push('enterprise') } - // Team members, Enterprise users see no plans + // Team/org members, Enterprise users see no plans return plans } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx index d2c6322be92..42581c86ff2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx @@ -34,7 +34,6 @@ import { getPlanTierDollars, isEnterprise, isFree, - isOrgPlan, isPaid, isPro, isTeam, @@ -294,12 +293,12 @@ export function Subscription() { const usageLimitRef = useRef(null) const hasInitializedInterval = useRef(false) - const hasOrgPlan = isOrgPlan(subscriptionData?.data?.plan) + const hasOrgScopedSubscription = Boolean(subscriptionData?.data?.isOrgScoped) const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading || - (hasOrgPlan && isOrgBillingLoading) + (hasOrgScopedSubscription && isOrgBillingLoading) const isCancelledAtPeriodEnd = subscriptionData?.data?.cancelAtPeriodEnd === true @@ -311,6 +310,12 @@ export function Subscription() { isPaid: isPaid(subscriptionData?.data?.plan) && hasPaidSubscriptionStatus(subscriptionData?.data?.status), + /** + * True when the subscription is attached to an org (regardless of plan + * name). Drives routing of usage-limit edits and whether we show pooled + * or personal usage. + */ + isOrgScoped: Boolean(subscriptionData?.data?.isOrgScoped), plan: subscriptionData?.data?.plan || 'free', status: subscriptionData?.data?.status || 'inactive', seats: getEffectiveSeats(subscriptionData?.data), @@ -364,16 +369,12 @@ export function Subscription() { const isTeamAdmin = ['owner', 'admin'].includes(userRole) const planIncludedAmount = - (subscription.isTeam || subscription.isEnterprise) && - isTeamAdmin && - organizationBillingData?.data + subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data ? organizationBillingData.data.minimumBillingAmount : getPlanTierCredits(subscription.plan) / CREDIT_MULTIPLIER const effectiveUsageLimit = - (subscription.isTeam || subscription.isEnterprise) && - isTeamAdmin && - organizationBillingData?.data + subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data ? organizationBillingData.data.totalUsageLimit : usageLimitData.currentLimit || usage.limit @@ -381,8 +382,7 @@ export function Subscription() { subscription.isPaid && planIncludedAmount > 0 && effectiveUsageLimit > planIncludedAmount const effectiveCurrentUsage = - (subscription.isTeam || subscription.isEnterprise) && - organizationBillingData?.data?.totalCurrentUsage != null + subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null ? organizationBillingData.data.totalCurrentUsage : usage.current @@ -390,8 +390,7 @@ export function Subscription() { const handleToggleOnDemand = useCallback(async () => { try { - const isOrgContext = - (subscription.isTeam || subscription.isEnterprise) && isTeamAdmin && activeOrgId + const isOrgContext = subscription.isOrgScoped && isTeamAdmin && activeOrgId if (isOnDemandActive) { if (!canDisableOnDemand) return @@ -420,8 +419,7 @@ export function Subscription() { }, [ isOnDemandActive, canDisableOnDemand, - subscription.isTeam, - subscription.isEnterprise, + subscription.isOrgScoped, isTeamAdmin, activeOrgId, planIncludedAmount, @@ -435,6 +433,7 @@ export function Subscription() { isTeam: subscription.isTeam, isEnterprise: subscription.isEnterprise, isPaid: subscription.isPaid, + isOrgScoped: subscription.isOrgScoped, plan: subscription.plan || 'free', status: subscription.status || 'inactive', }, @@ -448,6 +447,7 @@ export function Subscription() { isTeam: subscription.isTeam, isEnterprise: subscription.isEnterprise, isPaid: subscription.isPaid, + isOrgScoped: subscription.isOrgScoped, plan: subscription.plan || 'free', status: subscription.status || 'inactive', }, @@ -502,7 +502,7 @@ export function Subscription() { return } if (isBlocked) { - const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user' + const context = subscription.isOrgScoped ? 'organization' : 'user' openBillingPortal.mutate( { context, @@ -529,8 +529,7 @@ export function Subscription() { isDispute, isBlocked, subscription.isFree, - subscription.isTeam, - subscription.isEnterprise, + subscription.isOrgScoped, activeOrgId, doUpgrade, logger, @@ -591,13 +590,12 @@ export function Subscription() { : undefined } current={ - (subscription.isTeam || subscription.isEnterprise) && - organizationBillingData?.data?.totalCurrentUsage != null + subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null ? organizationBillingData.data.totalCurrentUsage : usage.current } limit={ - subscription.isEnterprise || subscription.isTeam + subscription.isOrgScoped ? organizationBillingData?.data?.totalUsageLimit : !subscription.isFree && (permissions.canEditUsageLimit || permissions.showTeamMemberView) @@ -612,31 +610,19 @@ export function Subscription() { logger.info('Usage limit updated')} /> ) : undefined @@ -905,7 +891,7 @@ export function Subscription() { setManagePlanModalOpen(false) if (!betterAuthSubscription.cancel) return try { - const isOrgSub = (subscription.isTeam || subscription.isEnterprise) && activeOrgId + const isOrgSub = subscription.isOrgScoped && activeOrgId const referenceId = isOrgSub ? activeOrgId : session?.user?.id || '' const returnUrl = getBaseUrl() + window.location.pathname await betterAuthSubscription.cancel({ returnUrl, referenceId }) @@ -917,7 +903,7 @@ export function Subscription() { onRestore={async () => { if (!betterAuthSubscription.restore) return try { - const isOrgSub = (subscription.isTeam || subscription.isEnterprise) && activeOrgId + const isOrgSub = subscription.isOrgScoped && activeOrgId const referenceId = isOrgSub ? activeOrgId : session?.user?.id || '' await betterAuthSubscription.restore({ referenceId }) await refetchSubscription() @@ -937,9 +923,7 @@ export function Subscription() { refetchSubscription()} /> @@ -974,8 +958,7 @@ export function Subscription() { disabled={openBillingPortal.isPending} onClick={() => { const portalWindow = window.open('', '_blank') - const context = - subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user' + const context = subscription.isOrgScoped ? 'organization' : 'user' openBillingPortal.mutate( { context, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx index 8f65e9b0283..5bd0434324d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx @@ -86,17 +86,28 @@ interface StatusTextConfig { } /** - * Determines if user can manage billing based on plan type and org role. + * Determines if user can manage billing based on plan type, subscription + * scope, and org role. * - * @param planType - The user's current plan type + * When the subscription is org-scoped (any subscription whose referenceId + * points at an organization — includes `pro_*` plans transferred to an + * org, not just team/enterprise), only owners/admins can manage billing. + * Otherwise any free/pro user can manage their own. + * + * @param planType - The user's current plan type (for display category) * @param orgRole - The user's role in the organization, if applicable + * @param isOrgScoped - Whether the subscription is attached to an org * @returns True if the user has billing management permissions */ -function canManageBilling(planType: PlanType, orgRole: OrgRole | null): boolean { - if (planType === 'free' || planType === 'pro') return true - if (planType === 'team' || planType === 'enterprise') { +function canManageBilling( + planType: PlanType, + orgRole: OrgRole | null, + isOrgScoped: boolean +): boolean { + if (isOrgScoped || planType === 'team' || planType === 'enterprise') { return orgRole === 'owner' || orgRole === 'admin' } + if (planType === 'free' || planType === 'pro') return true return false } @@ -247,7 +258,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const isCritical = isBlocked || progressPercentage >= USAGE_THRESHOLDS.CRITICAL const isWarning = !isCritical && progressPercentage >= USAGE_THRESHOLDS.WARNING - const userCanManageBilling = canManageBilling(planType, orgRole) + const isOrgScoped = Boolean(subscriptionData?.data?.isOrgScoped) + const userCanManageBilling = canManageBilling(planType, orgRole, isOrgScoped) const displayState: DisplayState = { planType, @@ -323,8 +335,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const contextMenuItems = useMemo( () => ({ - // Set limit: Only for Pro and Team admins (not free, not enterprise) - showSetLimit: (isPro || (isTeam && userCanManageBilling)) && !isEnterprise, + // Set limit: anyone who can manage billing on a paid non-enterprise + // plan. `userCanManageBilling` already enforces owner/admin for + // org-scoped subs (including `pro_*` attached to an org), so members + // of an org don't see this. + showSetLimit: userCanManageBilling && !isFree && !isEnterprise, // Upgrade to Pro: Only for free users showUpgradeToPro: isFree, // Upgrade to Team: Free users and Pro users with billing permission @@ -435,7 +450,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { if (isBlocked && userCanManageBilling) { try { - const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user' + const context = isOrgScoped ? 'organization' : 'user' const organizationId = subscriptionData?.data?.organization?.id const response = await fetch('/api/billing/portal', { diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index b5f634cf06a..fdb6b60a5bb 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client } from '@/lib/auth/auth-client' -import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' +import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' const logger = createLogger('OrganizationQueries') @@ -87,13 +87,17 @@ async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSigna return null } - const teamSubscription = response.data?.find( - (sub: any) => hasPaidSubscriptionStatus(sub.status) && isTeam(sub.plan) + // Any paid subscription attached to the org counts as its active sub. + // Priority: Enterprise > Team > Pro (matches `getHighestPrioritySubscription`). + // This intentionally includes `pro_*` plans that have been transferred + // to the org — they are pooled org-scoped subscriptions. + const entitled = (response.data || []).filter( + (sub: any) => hasPaidSubscriptionStatus(sub.status) && isPaid(sub.plan) ) - const enterpriseSubscription = response.data?.find( - (sub: any) => hasPaidSubscriptionStatus(sub.status) && isEnterprise(sub.plan) - ) - const activeSubscription = enterpriseSubscription || teamSubscription + const enterpriseSubscription = entitled.find((sub: any) => isEnterprise(sub.plan)) + const teamSubscription = entitled.find((sub: any) => isTeam(sub.plan)) + const proSubscription = entitled.find((sub: any) => !isEnterprise(sub.plan) && !isTeam(sub.plan)) + const activeSubscription = enterpriseSubscription || teamSubscription || proSubscription return activeSubscription || null } diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index 3900bb9d383..a7f98d033ba 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -17,11 +17,6 @@ export interface BillingUsageData { lastPeriodCopilotCost: number daysRemaining: number copilotCost: number - currentCredits: number - limitCredits: number - lastPeriodCostCredits: number - lastPeriodCopilotCostCredits: number - copilotCostCredits: number } /** @@ -30,10 +25,7 @@ export interface BillingUsageData { export interface SubscriptionBillingData { type: 'individual' | 'organization' plan: string - basePrice: number currentUsage: number - overageAmount: number - totalProjected: number usageLimit: number percentUsed: number isWarning: boolean @@ -41,18 +33,22 @@ export interface SubscriptionBillingData { daysRemaining: number creditBalance: number billingInterval: 'month' | 'year' - tierCredits: number - basePriceCredits: number - currentUsageCredits: number - overageAmountCredits: number - totalProjectedCredits: number - usageLimitCredits: number isPaid: boolean isPro: boolean isTeam: boolean isEnterprise: boolean + /** + * Whether the subscription is attached to an organization. Includes + * `pro_*` plans that have been transferred to an org; use this for + * scope-based decisions instead of `isTeam` / `isEnterprise`. + */ + isOrgScoped: boolean + /** Present when `isOrgScoped` is true. */ + organizationId: string | null status: string | null seats: number | null + /** Raw subscription metadata JSON from Stripe (e.g. billingInterval). */ + metadata: unknown stripeSubscriptionId: string | null periodEnd: string | null cancelAtPeriodEnd?: boolean @@ -61,16 +57,6 @@ export interface SubscriptionBillingData { billingBlockedReason?: 'payment_failed' | 'dispute' | null blockedByOrgOwner?: boolean organization?: { id: string; role: 'owner' | 'admin' | 'member' } - organizationData?: { - seatCount: number - memberCount: number - totalBasePrice: number - totalCurrentUsage: number - totalOverage: number - totalBasePriceCredits: number - totalCurrentUsageCredits: number - totalOverageCredits: number - } } /** diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 461c02f8460..20a78fafc78 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -37,7 +37,7 @@ import { } from '@/lib/auth/cimd' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' -import { writeBillingInterval } from '@/lib/billing/core/subscription' +import { syncSubscriptionPlan, writeBillingInterval } from '@/lib/billing/core/subscription' import { handleNewUser } from '@/lib/billing/core/usage' import { ensureOrganizationForTeamSubscription, @@ -2904,6 +2904,11 @@ export const auth = betterAuth({ { subscriptionId: subscription.id, dbPlan: subscription.plan, priceId } ) } + + // Persist the Stripe-resolved plan name to our DB row before + // any downstream work so subsequent reads see the fresh plan. + await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe) + const subscriptionForOrg = { ...subscription, plan: planFromStripe ?? subscription.plan, @@ -2981,6 +2986,13 @@ export const auth = betterAuth({ { subscriptionId: subscription.id, dbPlan: subscription.plan } ) } + + // Sync the DB's `plan` column to whatever Stripe currently + // says. better-auth's upgrade flow updates Stripe price, + // seats, and referenceId, but historically left `plan` + // stale (see `pro_6000` attached to an org in prod). + await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe) + const subscriptionForOrg = { ...subscription, plan: planFromStripe ?? subscription.plan, diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index c84353cc76a..67dbc0c66a6 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -3,23 +3,31 @@ import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { hasPaidSubscription } from '@/lib/billing' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' const logger = createLogger('BillingAuthorization') /** - * Check if a user is authorized to manage billing for a given reference ID - * Reference ID can be either a user ID (individual subscription) or organization ID (team subscription) + * Check if a user is authorized to manage billing for a given reference ID. + * Reference ID can be either a user ID (personal subscription) or an + * organization ID (org-scoped subscription — team, enterprise, or a + * `pro_*` plan transferred to an org). * - * This function also performs duplicate subscription validation for organizations: - * - Rejects if an organization already has an active subscription (prevents duplicates) - * - Personal subscriptions (referenceId === userId) skip this check to allow upgrades + * This function also performs duplicate subscription validation for + * organizations: + * - Rejects if an organization already has an active subscription (prevents + * duplicates). + * - Personal subscriptions skip this check to allow upgrades. */ export async function authorizeSubscriptionReference( userId: string, referenceId: string, action?: string ): Promise { - if (referenceId === userId) { + // `isOrgScopedSubscription` returns `false` when referenceId === userId, + // which is exactly the "personal subscription" case we want to allow + // without further checks. + if (!isOrgScopedSubscription({ referenceId }, userId)) { return true } diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 71a294ba1de..76fd7eb41df 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -1,11 +1,18 @@ import { db } from '@sim/db' -import { member, organization, userStats } from '@sim/db/schema' +import { member, organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' -import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' +import { + getHighestPrioritySubscription, + type HighestPrioritySubscription, +} from '@/lib/billing/core/plan' import { getUserUsageLimit } from '@/lib/billing/core/usage' import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers' +import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' +import { + ENTITLED_SUBSCRIPTION_STATUSES, + isOrgScopedSubscription, +} from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { toError } from '@/lib/core/utils/helpers' @@ -19,6 +26,16 @@ interface UsageData { isExceeded: boolean currentUsage: number limit: number + /** + * Whether the returned `currentUsage`/`limit` represent the user's + * individual slice (`'user'`) or the organization's pooled total and cap + * (`'organization'`). When `isExceeded` is driven by an org pool check, + * the pooled values are surfaced here so downstream error messages are + * accurate. + */ + scope: 'user' | 'organization' + /** Present only when `scope === 'organization'`. */ + organizationId: string | null } /** @@ -45,134 +62,213 @@ export async function checkUsageStatus( isExceeded: false, currentUsage, limit: 1000, + scope: 'user', + organizationId: null, } } - // Get usage limit from user_stats (per-user cap) - const limit = await getUserUsageLimit(userId, preloadedSubscription) + // Resolve the highest-priority subscription once so every branch below + // agrees on scope. The caller may have already loaded it. + const sub = + preloadedSubscription !== undefined + ? preloadedSubscription + : await getHighestPrioritySubscription(userId) + + // Primary limit from user_stats or org (routed by scope). + const limit = await getUserUsageLimit(userId, sub) logger.info('Using stored usage limit', { userId, limit }) - // Get actual usage from the database - const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) + // Usage baseline. + // Org-scoped: pooled sum across all org members (with pooled refresh). + // Personal: user's own currentPeriodCost (with personal refresh). + const subIsOrgScoped = isOrgScopedSubscription(sub, userId) - // If no stats record exists, create a default one - if (statsRecords.length === 0) { - logger.info('No usage stats found for user', { userId, limit }) + let currentUsage = 0 + let effectiveLimit = limit + let scope: 'user' | 'organization' = subIsOrgScoped ? 'organization' : 'user' + let blockingOrgId: string | null = subIsOrgScoped && sub ? sub.referenceId : null - return { - percentUsed: 0, - isWarning: false, - isExceeded: false, - currentUsage: 0, - limit, - } - } + if (subIsOrgScoped && sub) { + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) - const rawUsage = Number.parseFloat( - statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString() - ) - - // Deduct daily refresh credits for individual paid plans only. - // Org plans apply refresh at the pooled level in the org usage check below. - let dailyRefreshDeduction = 0 - if ( - preloadedSubscription && - isPaid(preloadedSubscription.plan) && - !isOrgPlan(preloadedSubscription.plan) && - preloadedSubscription.periodStart - ) { - const planDollars = getPlanTierDollars(preloadedSubscription.plan) - if (planDollars > 0) { - dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: [userId], - periodStart: preloadedSubscription.periodStart, - periodEnd: preloadedSubscription.periodEnd ?? null, - planDollars, - }) + let pooled = 0 + if (teamMembers.length > 0) { + const memberIds = teamMembers.map((tm) => tm.userId) + const memberStatsRows = await db + .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + for (const stats of memberStatsRows) { + pooled += Number.parseFloat(stats.current?.toString() || stats.total.toString()) + } + + if (isPaid(sub.plan) && sub.periodStart) { + const planDollars = getPlanTierDollars(sub.plan) + if (planDollars > 0) { + const refresh = await computeDailyRefreshConsumed({ + userIds: memberIds, + periodStart: sub.periodStart, + periodEnd: sub.periodEnd ?? null, + planDollars, + seats: sub.seats ?? 1, + }) + pooled = Math.max(0, pooled - refresh) + } + } } - } + currentUsage = pooled + } else { + // Personally-scoped: use this user's own row (defensive default 0). + const statsRecords = await db + .select() + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) - const currentUsage = Math.max(0, rawUsage - dailyRefreshDeduction) + if (statsRecords.length === 0) { + logger.info('No usage stats found for user', { userId, limit }) + return { + percentUsed: 0, + isWarning: false, + isExceeded: false, + currentUsage: 0, + limit, + scope: 'user', + organizationId: null, + } + } - const percentUsed = Math.min((currentUsage / limit) * 100, 100) + const rawUsage = Number.parseFloat( + statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString() + ) + + let refresh = 0 + if (sub && isPaid(sub.plan) && sub.periodStart) { + const planDollars = getPlanTierDollars(sub.plan) + if (planDollars > 0) { + refresh = await computeDailyRefreshConsumed({ + userIds: [userId], + periodStart: sub.periodStart, + periodEnd: sub.periodEnd ?? null, + planDollars, + }) + } + } + currentUsage = Math.max(0, rawUsage - refresh) + } - let isExceeded = currentUsage >= limit - let isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100 + // Defense-in-depth: even when the user's priority sub is personal, they + // may still be a member of an org whose pool has blown its cap (e.g. + // billed-account scenarios). Enforce every entitled-org cap they belong + // to and override the returned values when one is actually blocking — + // that way the error message surfaces the org number, not personal. try { const memberships = await db .select({ organizationId: member.organizationId }) .from(member) .where(eq(member.userId, userId)) - if (memberships.length > 0) { - for (const m of memberships) { - const orgRows = await db - .select({ id: organization.id, orgUsageLimit: organization.orgUsageLimit }) - .from(organization) - .where(eq(organization.id, m.organizationId)) - .limit(1) - if (orgRows.length) { - const org = orgRows[0] - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, org.id)) - - let pooledUsage = 0 - if (teamMembers.length > 0) { - const memberIds = teamMembers.map((tm) => tm.userId) - const allMemberStats = await db - .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - for (const stats of allMemberStats) { - pooledUsage += Number.parseFloat( - stats.current?.toString() || stats.total.toString() - ) - } - } - if ( - preloadedSubscription && - isPaid(preloadedSubscription.plan) && - preloadedSubscription.periodStart - ) { - const planDollars = getPlanTierDollars(preloadedSubscription.plan) - if (planDollars > 0) { - const memberIds = teamMembers.map((tm) => tm.userId) - const orgRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: memberIds, - periodStart: preloadedSubscription.periodStart, - periodEnd: preloadedSubscription.periodEnd ?? null, - planDollars, - seats: preloadedSubscription.seats ?? 1, - }) - pooledUsage = Math.max(0, pooledUsage - orgRefreshDeduction) - } - } - - const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0 - if (!orgCap || Number.isNaN(orgCap)) { - logger.warn('Organization missing usage limit', { orgId: org.id }) - } - if (pooledUsage >= orgCap) { - isExceeded = true - isWarning = false - break - } + + for (const m of memberships) { + // Skip the org the primary sub is already keyed to; we've already + // computed pooled usage against its cap above. + if (subIsOrgScoped && sub && sub.referenceId === m.organizationId) continue + + // Pull the full org subscription row — refresh math below needs + // THAT org's plan/period/seats, not the caller's primary sub. + const [orgSub] = await db + .select({ + plan: subscription.plan, + seats: subscription.seats, + periodStart: subscription.periodStart, + periodEnd: subscription.periodEnd, + }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, m.organizationId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) + if (!orgSub) continue + + const [org] = await db + .select({ id: organization.id, orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, m.organizationId)) + .limit(1) + if (!org) continue + + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, org.id)) + + let pooledUsage = 0 + if (teamMembers.length > 0) { + const memberIds = teamMembers.map((tm) => tm.userId) + const allMemberStats = await db + .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + for (const stats of allMemberStats) { + pooledUsage += Number.parseFloat(stats.current?.toString() || stats.total.toString()) } } + + // Refresh is driven by the org's OWN subscription period, plan + // dollars, and seats — not the caller's primary sub (which may be + // a personal Pro in this branch). + if (isPaid(orgSub.plan) && orgSub.periodStart) { + const planDollars = getPlanTierDollars(orgSub.plan) + if (planDollars > 0) { + const memberIds = teamMembers.map((tm) => tm.userId) + const orgRefreshDeduction = await computeDailyRefreshConsumed({ + userIds: memberIds, + periodStart: orgSub.periodStart, + periodEnd: orgSub.periodEnd ?? null, + planDollars, + seats: orgSub.seats ?? 1, + }) + pooledUsage = Math.max(0, pooledUsage - orgRefreshDeduction) + } + } + + const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0 + if (!orgCap || Number.isNaN(orgCap)) { + logger.warn('Organization missing usage limit', { orgId: org.id }) + } + if (orgCap > 0 && pooledUsage >= orgCap) { + currentUsage = pooledUsage + effectiveLimit = orgCap + scope = 'organization' + blockingOrgId = org.id + break + } } } catch (error) { logger.warn('Error checking organization usage limits', { error, userId }) } + const percentUsed = + effectiveLimit > 0 ? Math.min((currentUsage / effectiveLimit) * 100, 100) : 100 + const isExceeded = effectiveLimit > 0 && currentUsage >= effectiveLimit + const isWarning = !isExceeded && percentUsed >= WARNING_THRESHOLD + logger.info('Final usage statistics', { userId, currentUsage, - limit, + limit: effectiveLimit, percentUsed, isWarning, isExceeded, + scope, + organizationId: blockingOrgId, }) return { @@ -180,7 +276,9 @@ export async function checkUsageStatus( isWarning, isExceeded, currentUsage, - limit, + limit: effectiveLimit, + scope, + organizationId: blockingOrgId, } } catch (error) { logger.error('Error checking usage status', { @@ -200,6 +298,8 @@ export async function checkUsageStatus( isExceeded: true, // Block execution when we can't determine status currentUsage: 0, limit: 0, // Zero limit forces blocking + scope: 'user', + organizationId: null, } } } @@ -354,13 +454,18 @@ export async function checkServerSideUsageLimits( const usageData = await checkUsageStatus(userId, preloadedSubscription) + const formattedUsage = (usageData.currentUsage ?? 0).toFixed(2) + const formattedLimit = (usageData.limit ?? 0).toFixed(2) + const exceededMessage = + usageData.scope === 'organization' + ? `Organization usage limit exceeded: $${formattedUsage} pooled of $${formattedLimit} organization limit. Ask a team admin to raise the organization usage limit to continue.` + : `Usage limit exceeded: $${formattedUsage} used of $${formattedLimit} limit. Please upgrade your plan or raise your usage limit to continue.` + return { isExceeded: usageData.isExceeded, currentUsage: usageData.currentUsage, limit: usageData.limit, - message: usageData.isExceeded - ? `Usage limit exceeded: ${usageData.currentUsage?.toFixed(2) || 0}$ used of ${usageData.limit?.toFixed(2) || 0}$ limit. Please upgrade your plan to continue.` - : undefined, + message: usageData.isExceeded ? exceededMessage : undefined, } } catch (error) { logger.error('Error in server-side usage limit check', { diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts index 08d58f52772..560bd98bd3f 100644 --- a/apps/sim/lib/billing/client/upgrade.ts +++ b/apps/sim/lib/billing/client/upgrade.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { client, useSession, useSubscription } from '@/lib/auth/auth-client' -import { buildPlanName, isOrgPlan } from '@/lib/billing/plan-helpers' +import { buildPlanName, getDisplayPlanName, isPaid } from '@/lib/billing/plan-helpers' import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { organizationKeys } from '@/hooks/queries/organization' @@ -65,22 +65,26 @@ export function useSubscriptionUpgrade() { ) if (existingOrg) { - // Check if this org already has an active team subscription - const existingTeamSub = allSubscriptions.find( + // Check if this org already has ANY paid subscription — team, + // enterprise, or a `pro_*` that's been transferred to the org. + // Upgrading again would create a duplicate. + const existingOrgSub = allSubscriptions.find( (sub: any) => hasPaidSubscriptionStatus(sub.status) && sub.referenceId === existingOrg.id && - isOrgPlan(sub.plan) + isPaid(sub.plan) ) - if (existingTeamSub) { - logger.warn('Organization already has an active team subscription', { + if (existingOrgSub) { + logger.warn('Organization already has an active subscription', { userId, organizationId: existingOrg.id, - existingSubscriptionId: existingTeamSub.id, + existingSubscriptionId: existingOrgSub.id, + plan: existingOrgSub.plan, }) + const existingPlanName = getDisplayPlanName(existingOrgSub.plan) throw new Error( - 'This organization already has an active team subscription. Please manage it from the billing settings.' + `This organization is already on the ${existingPlanName} plan. Manage it from the billing settings.` ) } diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 2edb18f4af9..742fa5ac7b9 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { member, organization, subscription, user, userStats } from '@sim/db/schema' +import { member, organization, subscription, userStats } from '@sim/db/schema' import { and, eq, inArray } from 'drizzle-orm' import { getBillingInterval, @@ -8,22 +8,14 @@ import { } from '@/lib/billing/core/subscription' import { getUserUsageData } from '@/lib/billing/core/usage' import { getCreditBalance } from '@/lib/billing/credits/balance' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { - getPlanTierCredits, - getPlanTierDollars, - isEnterprise, - isOrgPlan, - isPaid, - isPro, - isTeam, -} from '@/lib/billing/plan-helpers' +import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, getFreeTierLimit, getPlanPricing, hasPaidSubscriptionStatus, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' @@ -65,44 +57,66 @@ export async function getOrganizationSubscription(organizationId: string) { */ /** - * Calculate overage billing for a user - * Returns only the amount that exceeds their subscription base price + * Check if a subscription is scoped to an organization by looking up its + * `referenceId` in the organization table. This is the authoritative + * answer — the plan name alone is unreliable because `pro_*` plans can be + * attached to organizations (and we should treat them as org-scoped). + * + * Use this in server contexts (webhooks, jobs) where we only have the + * subscription row, not a user perspective. If you do have a user id, + * `isOrgScopedSubscription(sub, userId)` is cheaper and equally correct. */ -export async function calculateUserOverage(userId: string): Promise<{ - basePrice: number - actualUsage: number - overageAmount: number - plan: string -} | null> { - try { - // Get user's subscription and usage data - const [subscription, usageData, userRecord] = await Promise.all([ - getHighestPrioritySubscription(userId), - getUserUsageData(userId), - db.select().from(user).where(eq(user.id, userId)).limit(1), - ]) - - if (userRecord.length === 0) { - logger.warn('User not found for overage calculation', { userId }) - return null - } - - const plan = subscription?.plan || 'free' - const { basePrice } = getPlanPricing(plan) - const actualUsage = usageData.currentUsage +export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Promise { + const rows = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, sub.referenceId)) + .limit(1) + return rows.length > 0 +} - // Calculate overage: any usage beyond what they already paid for - const overageAmount = Math.max(0, actualUsage - basePrice) +/** + * Aggregate raw pooled stats for all members of an organization in a single + * query. Used by org-scoped summary and overage calculations so we don't + * call `getUserUsageData` per-member — that helper now returns the entire + * pool for org-scoped subs, which would N-times-count the usage. + */ +async function aggregateOrgMemberStats(organizationId: string): Promise<{ + memberIds: string[] + currentPeriodCost: number + currentPeriodCopilotCost: number + lastPeriodCopilotCost: number +}> { + const rows = await db + .select({ + userId: member.userId, + currentPeriodCost: userStats.currentPeriodCost, + currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, + lastPeriodCopilotCost: userStats.lastPeriodCopilotCost, + }) + .from(member) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(eq(member.organizationId, organizationId)) + + let currentPeriodCost = new Decimal(0) + let currentPeriodCopilotCost = new Decimal(0) + let lastPeriodCopilotCost = new Decimal(0) + const memberIds: string[] = [] + + for (const row of rows) { + memberIds.push(row.userId) + currentPeriodCost = currentPeriodCost.plus(toDecimal(row.currentPeriodCost)) + currentPeriodCopilotCost = currentPeriodCopilotCost.plus( + toDecimal(row.currentPeriodCopilotCost) + ) + lastPeriodCopilotCost = lastPeriodCopilotCost.plus(toDecimal(row.lastPeriodCopilotCost)) + } - return { - basePrice, - actualUsage, - overageAmount, - plan, - } - } catch (error) { - logger.error('Failed to calculate user overage', { userId, error }) - return null + return { + memberIds, + currentPeriodCost: toNumber(currentPeriodCost), + currentPeriodCopilotCost: toNumber(currentPeriodCopilotCost), + lastPeriodCopilotCost: toNumber(lastPeriodCopilotCost), } } @@ -129,17 +143,11 @@ export async function calculateSubscriptionOverage(sub: { let totalOverageDecimal = new Decimal(0) - if (isTeam(sub.plan)) { - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) + const isOrgScoped = await isSubscriptionOrgScoped(sub) - let totalTeamUsageDecimal = new Decimal(0) - for (const m of members) { - const usage = await getUserUsageData(m.userId) - totalTeamUsageDecimal = totalTeamUsageDecimal.plus(toDecimal(usage.currentUsage)) - } + if (isOrgScoped) { + const pooled = await aggregateOrgMemberStats(sub.referenceId) + const totalTeamUsageDecimal = toDecimal(pooled.currentPeriodCost) const orgData = await db .select({ departedMemberUsage: organization.departedMemberUsage }) @@ -155,9 +163,8 @@ export async function calculateSubscriptionOverage(sub: { let dailyRefreshDeduction = 0 const planDollars = getPlanTierDollars(sub.plan) if (planDollars > 0 && sub.periodStart) { - const memberIds = members.map((m) => m.userId) dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: memberIds, + userIds: pooled.memberIds, periodStart: sub.periodStart, periodEnd: sub.periodEnd ?? null, planDollars, @@ -170,11 +177,14 @@ export async function calculateSubscriptionOverage(sub: { totalUsageWithDepartedDecimal.minus(toDecimal(dailyRefreshDeduction)) ) const { basePrice } = getPlanPricing(sub.plan ?? '') - const baseSubscriptionAmount = (sub.seats ?? 0) * basePrice + // Base = basePrice × seats (or × 1 when seats is null), mirroring + // Stripe's `price × quantity` for every paid non-enterprise plan. + const baseSubscriptionAmount = (sub.seats ?? 1) * basePrice totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(baseSubscriptionAmount)) - logger.info('Calculated team overage', { + logger.info('Calculated org-scoped overage', { subscriptionId: sub.id, + plan: sub.plan, currentMemberUsage: toNumber(totalTeamUsageDecimal), departedMemberUsage: toNumber(departedUsageDecimal), totalUsage: toNumber(totalUsageWithDepartedDecimal), @@ -182,47 +192,90 @@ export async function calculateSubscriptionOverage(sub: { totalOverage: toNumber(totalOverageDecimal), }) } else if (isPro(sub.plan)) { - // Pro plan: include snapshot if user joined a team - const usage = await getUserUsageData(sub.referenceId) - let totalProUsageDecimal = toDecimal(usage.currentUsage) - - // Add any snapshotted Pro usage (from when they joined a team) - const userStatsRows = await db - .select({ proPeriodCostSnapshot: userStats.proPeriodCostSnapshot }) + // Personal Pro finalization. Read this user's own row directly instead + // of going through `getUserUsageData` — that helper follows + // `getHighestPrioritySubscription`, which now prefers an org sub over + // a personal sub within the same tier. During the `cancelAtPeriodEnd` + // grace window (user has an active personal Pro AND is a member of a + // paid org), routing through the priority lookup would bill pooled + // org usage on the personal Pro's final invoice. + const [statsRow] = await db + .select({ + currentPeriodCost: userStats.currentPeriodCost, + proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, + }) .from(userStats) .where(eq(userStats.userId, sub.referenceId)) .limit(1) - if (userStatsRows.length > 0 && userStatsRows[0].proPeriodCostSnapshot) { - const snapshotUsageDecimal = toDecimal(userStatsRows[0].proPeriodCostSnapshot) - totalProUsageDecimal = totalProUsageDecimal.plus(snapshotUsageDecimal) - logger.info('Including snapshotted Pro usage in overage calculation', { + const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0 + const snapshotUsage = statsRow ? toNumber(toDecimal(statsRow.proPeriodCostSnapshot)) : 0 + + // Attribution rule: if the user joined a paid org mid-cycle, their + // pre-join usage is in `proPeriodCostSnapshot` and their post-join + // usage (still accumulating in `currentPeriodCost`) now belongs to the + // org pool. This personal-Pro invoice must only bill the pre-join + // portion, otherwise post-join org usage gets double-charged here and + // on the org's own invoice. + const joinedOrgMidCycle = snapshotUsage > 0 + const totalProUsageDecimal = joinedOrgMidCycle + ? toDecimal(snapshotUsage) + : toDecimal(personalCurrentUsage) + + if (joinedOrgMidCycle) { + logger.info('Billing personal Pro only for pre-join usage (user joined org mid-cycle)', { userId: sub.referenceId, - currentUsage: usage.currentUsage, - snapshotUsage: toNumber(snapshotUsageDecimal), - totalProUsage: toNumber(totalProUsageDecimal), + preJoinUsage: snapshotUsage, + postJoinUsageOnMemberRow: personalCurrentUsage, + subscriptionId: sub.id, }) } + // Apply personal daily refresh for this sub's own period. + let dailyRefreshDeduction = 0 + const planDollars = getPlanTierDollars(sub.plan) + if (planDollars > 0 && sub.periodStart) { + dailyRefreshDeduction = await computeDailyRefreshConsumed({ + userIds: [sub.referenceId], + periodStart: sub.periodStart, + periodEnd: sub.periodEnd ?? null, + planDollars, + }) + } + + const effectiveUsageDecimal = Decimal.max( + 0, + totalProUsageDecimal.minus(toDecimal(dailyRefreshDeduction)) + ) const { basePrice } = getPlanPricing(sub.plan ?? '') - totalOverageDecimal = Decimal.max(0, totalProUsageDecimal.minus(basePrice)) + totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(basePrice)) - logger.info('Calculated pro overage', { + logger.info('Calculated personal pro overage', { subscriptionId: sub.id, - totalProUsage: toNumber(totalProUsageDecimal), + joinedOrgMidCycle, + personalCurrentUsage, + snapshot: snapshotUsage, + billedUsage: toNumber(totalProUsageDecimal), + dailyRefreshDeduction, basePrice, totalOverage: toNumber(totalOverageDecimal), }) } else { - // Free plan or unknown plan type - const usage = await getUserUsageData(sub.referenceId) + // Free plan or unknown plan type scoped to a user. Read personal row + // directly for the same reason as the Pro branch above. + const [statsRow] = await db + .select({ currentPeriodCost: userStats.currentPeriodCost }) + .from(userStats) + .where(eq(userStats.userId, sub.referenceId)) + .limit(1) + const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0 const { basePrice } = getPlanPricing(sub.plan || 'free') - totalOverageDecimal = Decimal.max(0, toDecimal(usage.currentUsage).minus(basePrice)) + totalOverageDecimal = Decimal.max(0, toDecimal(personalCurrentUsage).minus(basePrice)) logger.info('Calculated overage for plan', { subscriptionId: sub.id, plan: sub.plan || 'free', - usage: usage.currentUsage, + usage: personalCurrentUsage, basePrice, totalOverage: toNumber(totalOverageDecimal), }) @@ -240,10 +293,7 @@ export async function getSimplifiedBillingSummary( ): Promise<{ type: 'individual' | 'organization' plan: string - basePrice: number currentUsage: number - overageAmount: number - totalProjected: number usageLimit: number percentUsed: number isWarning: boolean @@ -251,17 +301,21 @@ export async function getSimplifiedBillingSummary( daysRemaining: number creditBalance: number billingInterval: 'month' | 'year' - tierCredits: number - basePriceCredits: number - currentUsageCredits: number - overageAmountCredits: number - totalProjectedCredits: number - usageLimitCredits: number // Subscription details isPaid: boolean isPro: boolean isTeam: boolean isEnterprise: boolean + /** + * True when the subscription is attached to an organization rather than + * the user. Includes `pro_*` plans that have been transferred to an org; + * prefer this over `isTeam` / `isEnterprise` for scope decisions (which + * API context to use, whether to pool usage, whether the limit is edited + * at the org level, etc.). + */ + isOrgScoped: boolean + /** Present when `isOrgScoped` is true. */ + organizationId: string | null status: string | null seats: number | null metadata: any @@ -281,21 +335,6 @@ export async function getSimplifiedBillingSummary( lastPeriodCopilotCost: number daysRemaining: number copilotCost: number - currentCredits: number - limitCredits: number - lastPeriodCostCredits: number - lastPeriodCopilotCostCredits: number - copilotCostCredits: number - } - organizationData?: { - seatCount: number - memberCount: number - totalBasePrice: number - totalCurrentUsage: number - totalOverage: number - totalBasePriceCredits: number - totalCurrentUsageCredits: number - totalOverageCredits: number } }> { try { @@ -314,6 +353,11 @@ export async function getSimplifiedBillingSummary( const planIsPro = hasPaidEntitlement && isPro(plan) const planIsTeam = hasPaidEntitlement && isTeam(plan) const planIsEnterprise = hasPaidEntitlement && isEnterprise(plan) + // Source of truth for "is this subscription at org level?" is the + // subscription's referenceId, not its plan name. A `pro_6000` attached + // to an org is org-scoped even though `isTeam` would return false. + const orgScoped = isOrgScopedSubscription(subscription, userId) + const subscriptionOrgId = orgScoped && subscription ? subscription.referenceId : null if (organizationId) { // Organization billing summary @@ -321,56 +365,15 @@ export async function getSimplifiedBillingSummary( return getDefaultBillingSummary('organization') } - // Get all organization members - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, organizationId)) - - const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan) - // Use licensed seats from Stripe as source of truth - const licensedSeats = subscription.seats ?? 0 - const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription - - let totalCurrentUsageDecimal = new Decimal(0) - let totalCopilotCostDecimal = new Decimal(0) - let totalLastPeriodCopilotCostDecimal = new Decimal(0) - - // Calculate total team usage across all members - for (const memberInfo of members) { - const memberUsageData = await getUserUsageData(memberInfo.userId) - totalCurrentUsageDecimal = totalCurrentUsageDecimal.plus( - toDecimal(memberUsageData.currentUsage) - ) - - // Fetch copilot cost for this member - const memberStats = await db - .select({ - currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, - lastPeriodCopilotCost: userStats.lastPeriodCopilotCost, - }) - .from(userStats) - .where(eq(userStats.userId, memberInfo.userId)) - .limit(1) - - if (memberStats.length > 0) { - totalCopilotCostDecimal = totalCopilotCostDecimal.plus( - toDecimal(memberStats[0].currentPeriodCopilotCost) - ) - totalLastPeriodCopilotCostDecimal = totalLastPeriodCopilotCostDecimal.plus( - toDecimal(memberStats[0].lastPeriodCopilotCost) - ) - } - } - - const totalCurrentUsage = toNumber(totalCurrentUsageDecimal) - const totalCopilotCost = toNumber(totalCopilotCostDecimal) - const totalLastPeriodCopilotCost = toNumber(totalLastPeriodCopilotCostDecimal) + // Pool usage/copilot across all org members in a single query — do + // NOT call `getUserUsageData` per member because that now returns the + // entire pool for org-scoped subs, which would N-times-count. + const pooled = await aggregateOrgMemberStats(organizationId) - // Calculate team-level overage: total usage beyond what was already paid to Stripe - const totalOverage = toNumber(Decimal.max(0, totalCurrentUsageDecimal.minus(totalBasePrice))) + const totalCurrentUsage = pooled.currentPeriodCost + const totalCopilotCost = pooled.currentPeriodCopilotCost + const totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost - // Get user's personal limits for warnings const percentUsed = usageData.limit > 0 ? Math.round((usageData.currentUsage / usageData.limit) * 100) : 0 @@ -383,16 +386,12 @@ export async function getSimplifiedBillingSummary( : 0 const orgCredits = await getCreditBalance(userId) - const orgTotalProjected = totalBasePrice + totalOverage const orgBillingInterval = getBillingInterval(subscription.metadata as SubscriptionMetadata) return { type: 'organization', plan: subscription.plan, - basePrice: totalBasePrice, currentUsage: totalCurrentUsage, - overageAmount: totalOverage, - totalProjected: orgTotalProjected, usageLimit: usageData.limit, percentUsed, isWarning: percentUsed >= 80 && percentUsed < 100, @@ -400,17 +399,13 @@ export async function getSimplifiedBillingSummary( daysRemaining, creditBalance: orgCredits.balance, billingInterval: orgBillingInterval, - tierCredits: getPlanTierCredits(subscription.plan), - basePriceCredits: dollarsToCredits(totalBasePrice), - currentUsageCredits: dollarsToCredits(totalCurrentUsage), - overageAmountCredits: dollarsToCredits(totalOverage), - totalProjectedCredits: dollarsToCredits(orgTotalProjected), - usageLimitCredits: dollarsToCredits(usageData.limit), // Subscription details isPaid: planIsPaid, isPro: planIsPro, isTeam: planIsTeam, isEnterprise: planIsEnterprise, + isOrgScoped: true, + organizationId: organizationId, status: subscription.status || null, seats: subscription.seats || null, metadata: subscription.metadata || null, @@ -430,29 +425,13 @@ export async function getSimplifiedBillingSummary( lastPeriodCopilotCost: totalLastPeriodCopilotCost, daysRemaining, copilotCost: totalCopilotCost, - currentCredits: dollarsToCredits(usageData.currentUsage), - limitCredits: dollarsToCredits(usageData.limit), - lastPeriodCostCredits: dollarsToCredits(usageData.lastPeriodCost), - lastPeriodCopilotCostCredits: dollarsToCredits(totalLastPeriodCopilotCost), - copilotCostCredits: dollarsToCredits(totalCopilotCost), - }, - organizationData: { - seatCount: licensedSeats, - memberCount: members.length, - totalBasePrice, - totalCurrentUsage, - totalOverage, - totalBasePriceCredits: dollarsToCredits(totalBasePrice), - totalCurrentUsageCredits: dollarsToCredits(totalCurrentUsage), - totalOverageCredits: dollarsToCredits(totalOverage), }, } } - // Individual billing summary - const { basePrice } = getPlanPricing(plan) - - // Fetch user stats for copilot cost breakdown + // Individual billing summary. Fetch copilot cost for the breakdown + // inside `usage`; pool across org members when org-scoped so the + // display numbers match what enforcement sees. const userStatsRows = await db .select({ currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, @@ -468,52 +447,17 @@ export async function getSimplifiedBillingSummary( const lastPeriodCopilotCost = userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].lastPeriodCopilotCost)) : 0 - // For team and enterprise plans, calculate total team usage instead of individual usage - let currentUsage = usageData.currentUsage + const currentUsage = usageData.currentUsage let totalCopilotCost = copilotCost let totalLastPeriodCopilotCost = lastPeriodCopilotCost - if (isOrgPlan(plan) && subscription?.referenceId) { - // Get all team members and sum their usage - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, subscription.referenceId)) - - let totalTeamUsageDecimal = new Decimal(0) - let totalTeamCopilotCostDecimal = new Decimal(0) - let totalTeamLastPeriodCopilotCostDecimal = new Decimal(0) - for (const teamMember of teamMembers) { - const memberUsageData = await getUserUsageData(teamMember.userId) - totalTeamUsageDecimal = totalTeamUsageDecimal.plus(toDecimal(memberUsageData.currentUsage)) - - // Fetch copilot cost for this team member - const memberStats = await db - .select({ - currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, - lastPeriodCopilotCost: userStats.lastPeriodCopilotCost, - }) - .from(userStats) - .where(eq(userStats.userId, teamMember.userId)) - .limit(1) - - if (memberStats.length > 0) { - totalTeamCopilotCostDecimal = totalTeamCopilotCostDecimal.plus( - toDecimal(memberStats[0].currentPeriodCopilotCost) - ) - totalTeamLastPeriodCopilotCostDecimal = totalTeamLastPeriodCopilotCostDecimal.plus( - toDecimal(memberStats[0].lastPeriodCopilotCost) - ) - } - } - currentUsage = toNumber(totalTeamUsageDecimal) - totalCopilotCost = toNumber(totalTeamCopilotCostDecimal) - totalLastPeriodCopilotCost = toNumber(totalTeamLastPeriodCopilotCostDecimal) + if (orgScoped && subscription?.referenceId) { + const pooled = await aggregateOrgMemberStats(subscription.referenceId) + totalCopilotCost = pooled.currentPeriodCopilotCost + totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost } - const overageAmount = toNumber(Decimal.max(0, toDecimal(currentUsage).minus(basePrice))) const percentUsed = usageData.limit > 0 ? (currentUsage / usageData.limit) * 100 : 0 - // Calculate days remaining in billing period const daysRemaining = usageData.billingPeriodEnd ? Math.max( 0, @@ -522,7 +466,6 @@ export async function getSimplifiedBillingSummary( : 0 const userCredits = await getCreditBalance(userId) - const individualTotalProjected = basePrice + overageAmount const individualBillingInterval = getBillingInterval( subscription?.metadata as SubscriptionMetadata ) @@ -530,10 +473,7 @@ export async function getSimplifiedBillingSummary( return { type: 'individual', plan, - basePrice, - currentUsage: currentUsage, - overageAmount, - totalProjected: individualTotalProjected, + currentUsage, usageLimit: usageData.limit, percentUsed, isWarning: percentUsed >= 80 && percentUsed < 100, @@ -541,17 +481,13 @@ export async function getSimplifiedBillingSummary( daysRemaining, creditBalance: userCredits.balance, billingInterval: individualBillingInterval, - tierCredits: getPlanTierCredits(plan), - basePriceCredits: dollarsToCredits(basePrice), - currentUsageCredits: dollarsToCredits(currentUsage), - overageAmountCredits: dollarsToCredits(overageAmount), - totalProjectedCredits: dollarsToCredits(individualTotalProjected), - usageLimitCredits: dollarsToCredits(usageData.limit), // Subscription details isPaid: planIsPaid, isPro: planIsPro, isTeam: planIsTeam, isEnterprise: planIsEnterprise, + isOrgScoped: orgScoped, + organizationId: subscriptionOrgId, status: subscription?.status || null, seats: subscription?.seats || null, metadata: subscription?.metadata || null, @@ -571,11 +507,6 @@ export async function getSimplifiedBillingSummary( lastPeriodCopilotCost: totalLastPeriodCopilotCost, daysRemaining, copilotCost: totalCopilotCost, - currentCredits: dollarsToCredits(currentUsage), - limitCredits: dollarsToCredits(usageData.limit), - lastPeriodCostCredits: dollarsToCredits(usageData.lastPeriodCost), - lastPeriodCopilotCostCredits: dollarsToCredits(totalLastPeriodCopilotCost), - copilotCostCredits: dollarsToCredits(totalCopilotCost), }, } } catch (error) { @@ -592,10 +523,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { return { type, plan: 'free', - basePrice: 0, currentUsage: 0, - overageAmount: 0, - totalProjected: 0, usageLimit: freeTierLimit, percentUsed: 0, isWarning: false, @@ -603,17 +531,13 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { daysRemaining: 0, creditBalance: 0, billingInterval: 'month' as const, - tierCredits: 0, - basePriceCredits: 0, - currentUsageCredits: 0, - overageAmountCredits: 0, - totalProjectedCredits: 0, - usageLimitCredits: dollarsToCredits(freeTierLimit), // Subscription details isPaid: false, isPro: false, isTeam: false, isEnterprise: false, + isOrgScoped: false, + organizationId: null, status: null, seats: null, metadata: null, @@ -632,23 +556,6 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { lastPeriodCopilotCost: 0, daysRemaining: 0, copilotCost: 0, - currentCredits: 0, - limitCredits: dollarsToCredits(freeTierLimit), - lastPeriodCostCredits: 0, - lastPeriodCopilotCostCredits: 0, - copilotCostCredits: 0, }, - ...(type === 'organization' && { - organizationData: { - seatCount: 0, - memberCount: 0, - totalBasePrice: 0, - totalCurrentUsage: 0, - totalOverage: 0, - totalBasePriceCredits: 0, - totalCurrentUsageCredits: 0, - totalOverageCredits: 0, - }, - }), } } diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index c025e722b6d..b5484ddc868 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -5,7 +5,7 @@ import { and, eq, inArray } from 'drizzle-orm' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { getPlanPricing } from '@/lib/billing/core/billing' import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' +import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, getEffectiveSeats, @@ -162,7 +162,12 @@ export async function getOrganizationBillingData( // Get per-seat pricing for the plan const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan) - const licensedSeats = subscription.seats ?? 0 + // Licensed seats mirrors Stripe's subscription quantity. Default to 1 + // for null rows to match the unified `basePrice × (seats ?? 1)` formula + // used in `getOrgUsageLimit`, `updateOrganizationUsageLimit`, overage, + // and threshold billing — so admin dashboard math agrees with + // enforcement math. + const licensedSeats = subscription.seats ?? 1 // For seat count used in UI (invitations, team management): // Team: seats column (Stripe quantity) @@ -263,18 +268,25 @@ export async function updateOrganizationUsageLimit( } } - // Only team plans can update their usage limits - if (!isTeam(subscription.plan)) { + // Any non-enterprise, non-free plan attached to an org can have its + // usage limit edited at the org level. This intentionally covers + // `pro_*` plans that have been transferred to an organization — + // they pool usage and are enforced against `organization.orgUsageLimit` + // just like team plans are. + if (!isPaid(subscription.plan)) { return { success: false, - error: 'Only team organizations can update usage limits', + error: 'Organization is not on a paid plan', } } const { basePrice } = getPlanPricing(subscription.plan) - const minimumLimit = (subscription.seats ?? 0) * basePrice + // Minimum = basePrice × seats (or × 1 if no seat count), mirroring + // Stripe's `price × quantity`. Keep this in sync with + // `getOrgUsageLimit` and `setUsageLimitForCredits`. + const seatCount = subscription.seats ?? 1 + const minimumLimit = seatCount * basePrice - // Validate new limit is not below minimum if (newLimit < minimumLimit) { return { success: false, diff --git a/apps/sim/lib/billing/core/plan.ts b/apps/sim/lib/billing/core/plan.ts index efed6996b3a..cb8fea848db 100644 --- a/apps/sim/lib/billing/core/plan.ts +++ b/apps/sim/lib/billing/core/plan.ts @@ -15,7 +15,17 @@ export type HighestPrioritySubscription = Awaited Team > Pro > Free + * + * Selection order: + * 1. Plan tier: Enterprise > Team > Pro > Free + * 2. Within the same tier, **org-scoped subs beat personally-scoped subs**. + * + * The tie-break matters because a user can legitimately hold both scopes + * at once — e.g. they accepted an org invite while their own personal Pro + * is still in its `cancelAtPeriodEnd` grace window. In that case the org + * is already paying for their usage, so pooled resources should win over + * the runoff personal sub; otherwise usage, credits, and rate limits would + * leak onto the user's row until the next billing cycle. */ export async function getHighestPrioritySubscription(userId: string) { try { @@ -59,17 +69,19 @@ export async function getHighestPrioritySubscription(userId: string) { } } - const allSubs = [...personalSubs, ...orgSubs] + if (personalSubs.length === 0 && orgSubs.length === 0) return null - if (allSubs.length === 0) return null + // Within each tier, prefer org-scoped over personally-scoped. + const pickAtTier = (predicate: (sub: (typeof personalSubs)[number]) => boolean) => + orgSubs.find(predicate) ?? personalSubs.find(predicate) - const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s)) + const enterpriseSub = pickAtTier(checkEnterprisePlan) if (enterpriseSub) return enterpriseSub - const teamSub = allSubs.find((s) => checkTeamPlan(s)) + const teamSub = pickAtTier(checkTeamPlan) if (teamSub) return teamSub - const proSub = allSubs.find((s) => checkProPlan(s)) + const proSub = pickAtTier(checkProPlan) if (proSub) return proSub return null diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 2e62e61206f..2a870c12e5f 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -61,6 +61,37 @@ export async function writeBillingInterval( .where(eq(subscription.id, subscriptionId)) } +/** + * Sync the subscription's `plan` column to match what Stripe currently + * says it is. This closes a historical gap where Stripe-side plan changes + * (e.g. Pro → Team upgrades, tier swaps) updated `seats` / `referenceId` / + * pricing at Stripe but left the DB's `plan` column stale (see + * customer sub `pro_6000` attached to an org with `seats=2`). + * + * Returns `true` if a write was issued, `false` if no update was needed. + */ +export async function syncSubscriptionPlan( + subscriptionId: string, + currentPlan: string | null, + planFromStripe: string | null +): Promise { + if (!planFromStripe) return false + if (currentPlan === planFromStripe) return false + + await db + .update(subscription) + .set({ plan: planFromStripe }) + .where(eq(subscription.id, subscriptionId)) + + logger.info('Synced subscription plan name from Stripe', { + subscriptionId, + previousPlan: currentPlan, + newPlan: planFromStripe, + }) + + return true +} + /** * Check if a referenceId (user ID or org ID) has a paid subscription row. * Used for duplicate subscription prevention and transfer safety. diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 75058dcf4e3..ccd37807a9a 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -14,14 +14,7 @@ import { type HighestPrioritySubscription, } from '@/lib/billing/core/plan' import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { - getPlanTierDollars, - isEnterprise, - isFree, - isOrgPlan, - isPaid, - isPro, -} from '@/lib/billing/plan-helpers' +import { getPlanTierDollars, isEnterprise, isFree, isPaid, isPro } from '@/lib/billing/plan-helpers' import { canEditUsageLimit, getFreeTierLimit, @@ -29,6 +22,7 @@ import { getPlanPricing, hasPaidSubscriptionStatus, hasUsableSubscriptionAccess, + isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types' import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' @@ -46,9 +40,15 @@ export interface OrgUsageLimitResult { } /** - * Calculates the effective usage limit for a team or enterprise organization. - * - Enterprise: Uses orgUsageLimit directly (fixed pricing) - * - Team: Uses orgUsageLimit but never below seats × basePrice + * Calculates the effective usage limit for an organization-scoped plan. + * - Enterprise: Uses orgUsageLimit directly (fixed pricing). + * - Everything else (team, plus `pro_*` transferred to an org): minimum + * floor is `basePrice × seats`, mirroring Stripe's `price × quantity`. + * `seats` defaults to 1 when null (matches Stripe's default quantity). + * + * Returns `{ limit, minimum }` where `limit` is the greater of the + * configured `orgUsageLimit` and the computed minimum, and `minimum` is + * the plan-driven floor. */ export async function getOrgUsageLimit( organizationId: string, @@ -76,15 +76,23 @@ export async function getOrgUsageLimit( } const { basePrice } = getPlanPricing(plan) - const minimum = (seats ?? 0) * basePrice + // Minimum floor = basePrice × seats. Stripe bills `price × quantity` + // for every paid non-enterprise plan, and `seats` is the mirror of the + // Stripe subscription's quantity. Personal Pro subs have seats=null → + // floor = basePrice × 1 = basePrice; team subs with N seats → basePrice + // × N; and `pro_*` plans that were transferred to an org (data drift) + // keep whatever quantity Stripe is actually charging for. + const seatCount = seats ?? 1 + const minimum = seatCount * basePrice if (configured !== null) { return { limit: Math.max(configured, minimum), minimum } } - logger.warn('Team org missing usage limit, using seats × basePrice fallback', { + logger.warn('Org missing usage limit, using plan-driven minimum as fallback', { orgId: organizationId, - seats, + plan, + seats: seatCount, minimum, }) return { limit: minimum, minimum } @@ -150,11 +158,14 @@ export async function getUserUsageData(userId: string): Promise { } const stats = userStatsData[0] + const orgScoped = isOrgScopedSubscription(subscription, userId) + let currentUsageDecimal = toDecimal(stats.currentPeriodCost) - // For Pro users, include any snapshotted usage (from when they joined a team) - // This ensures they see their total Pro usage in the UI - if (subscription && isPro(subscription.plan) && subscription.referenceId === userId) { + // For personally-scoped Pro users, include any snapshotted usage (from + // when they previously joined a team) so the display reflects total Pro + // usage. Org-scoped subs use pooled values computed below instead. + if (subscription && isPro(subscription.plan) && !orgScoped) { const snapshotUsageDecimal = toDecimal(stats.proPeriodCostSnapshot) if (snapshotUsageDecimal.greaterThan(0)) { currentUsageDecimal = currentUsageDecimal.plus(snapshotUsageDecimal) @@ -166,47 +177,82 @@ export async function getUserUsageData(userId: string): Promise { }) } } - const currentUsage = toNumber(currentUsageDecimal) + let currentUsage = toNumber(currentUsageDecimal) - // Determine usage limit based on plan type + // Determine usage limit based on subscription scope (not plan name). + // Any subscription whose referenceId is an organization is org-scoped, + // including `pro_*` plans that have been transferred to an org. let limit: number - if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) { - // Free/Pro: Use individual user limit from userStats - limit = stats.currentUsageLimit - ? toNumber(toDecimal(stats.currentUsageLimit)) - : getFreeTierLimit() - } else { - // Team/Enterprise: Use organization limit + if (orgScoped && subscription) { + // Org-scoped: use the organization's pooled limit, and surface the + // organization's pooled current period usage (not this user's slice). const orgLimit = await getOrgUsageLimit( subscription.referenceId, subscription.plan, subscription.seats ) limit = orgLimit.limit + + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, subscription.referenceId)) + + if (teamMembers.length > 0) { + const memberIds = teamMembers.map((m) => m.userId) + const rows = await db + .select({ current: userStats.currentPeriodCost }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + let pooled = toDecimal(0) + for (const row of rows) { + pooled = pooled.plus(toDecimal(row.current)) + } + currentUsage = toNumber(pooled) + } + } else { + // Personally-scoped Free/Pro: use individual user limit from userStats + limit = stats.currentUsageLimit + ? toNumber(toDecimal(stats.currentUsageLimit)) + : getFreeTierLimit() } // Derive billing period dates from subscription (source of truth). const billingPeriodStart = subscription?.periodStart ?? null const billingPeriodEnd = subscription?.periodEnd ?? null - // Compute daily refresh deduction for individual (non-org) paid plans. - // Org plans apply refresh at the pooled level in getEffectiveCurrentPeriodCost. + // Compute daily refresh deduction. Apply at the pooled level for + // org-scoped subscriptions (which includes `pro_*` plans attached to + // an organization) and at the individual level otherwise. let dailyRefreshConsumed = 0 - if ( - subscription && - isPaid(subscription.plan) && - !isOrgPlan(subscription.plan) && - billingPeriodStart - ) { + if (subscription && isPaid(subscription.plan) && billingPeriodStart) { const planDollars = getPlanTierDollars(subscription.plan) if (planDollars > 0) { - dailyRefreshConsumed = await computeDailyRefreshConsumed({ - userIds: [userId], - periodStart: billingPeriodStart, - periodEnd: billingPeriodEnd, - planDollars, - }) + if (orgScoped) { + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, subscription.referenceId)) + const memberIds = teamMembers.map((m) => m.userId) + if (memberIds.length > 0) { + dailyRefreshConsumed = await computeDailyRefreshConsumed({ + userIds: memberIds, + periodStart: billingPeriodStart, + periodEnd: billingPeriodEnd, + planDollars, + seats: subscription.seats ?? 1, + }) + } + } else { + dailyRefreshConsumed = await computeDailyRefreshConsumed({ + userIds: [userId], + periodStart: billingPeriodStart, + periodEnd: billingPeriodEnd, + planDollars, + }) + } } } @@ -246,21 +292,16 @@ export async function getUserUsageLimitInfo(userId: string): Promise { return // User already has usage stats } - // Check user's subscription to determine initial limit + // Check user's subscription to determine initial limit. Org-scoped + // subscriptions (including `pro_*` attached to an org) null out the + // individual limit and defer to the organization's usage limit. const subscription = await getHighestPrioritySubscription(userId) - const isTeamOrEnterprise = subscription && isOrgPlan(subscription.plan) + const orgScoped = isOrgScopedSubscription(subscription, userId) - // Create initial usage stats await db.insert(userStats).values({ id: generateId(), userId, - // Team/enterprise: null (use org limit), Free/Pro: individual limit - currentUsageLimit: isTeamOrEnterprise ? null : getFreeTierLimit().toString(), + currentUsageLimit: orgScoped ? null : getFreeTierLimit().toString(), usageLimitUpdatedAt: new Date(), }) logger.info('Initialized user stats', { userId, plan: subscription?.plan || 'free', - hasIndividualLimit: !isTeamOrEnterprise, + hasIndividualLimit: !orgScoped, }) } @@ -330,11 +379,14 @@ export async function updateUserUsageLimit( try { const subscription = await getHighestPrioritySubscription(userId) - // Team/enterprise users don't have individual limits - if (subscription && isOrgPlan(subscription.plan)) { + // Org-scoped subscriptions (Team/Enterprise, and `pro_*` plans that have + // been attached to an organization) do not have individual limits — they + // are edited through the organization endpoint. + if (isOrgScopedSubscription(subscription, userId)) { return { success: false, - error: 'Team and enterprise members use organization limits', + error: + 'This subscription is managed at the organization level. Update the organization usage limit instead.', } } @@ -389,9 +441,12 @@ export async function updateUserUsageLimit( } /** - * Get usage limit for a user (used by checkUsageStatus for server-side checks) - * Free/Pro: Individual user limit from userStats - * Team/Enterprise: Organization limit + * Get usage limit for a user (used by checkUsageStatus for server-side checks). + * + * Branches on subscription scope, not plan name: + * - Org-scoped (any subscription whose `referenceId` is an organization, + * including `pro_*` plans attached to an org): organization usage limit. + * - Personally-scoped Free/Pro: individual user limit from userStats. */ export async function getUserUsageLimit( userId: string, @@ -402,46 +457,45 @@ export async function getUserUsageLimit( ? preloadedSubscription : await getHighestPrioritySubscription(userId) - if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) { - // Free/Pro: Use individual limit from userStats - const userStatsQuery = await db - .select({ currentUsageLimit: userStats.currentUsageLimit }) - .from(userStats) - .where(eq(userStats.userId, userId)) + if (isOrgScopedSubscription(subscription, userId) && subscription) { + const orgExists = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, subscription.referenceId)) .limit(1) - if (userStatsQuery.length === 0) { - throw new Error( - `No user stats record found for userId: ${userId}. User must be properly initialized before execution.` - ) + if (orgExists.length === 0) { + throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`) } - // Individual limits should never be null for free/pro users - if (!userStatsQuery[0].currentUsageLimit) { - throw new Error( - `Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}. User stats must be properly initialized.` - ) - } - - return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit)) + const orgLimit = await getOrgUsageLimit( + subscription.referenceId, + subscription.plan, + subscription.seats + ) + return orgLimit.limit } - // Team/Enterprise: Verify org exists then use organization limit - const orgExists = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, subscription.referenceId)) + + // Personally-scoped Free/Pro: Use individual limit from userStats + const userStatsQuery = await db + .select({ currentUsageLimit: userStats.currentUsageLimit }) + .from(userStats) + .where(eq(userStats.userId, userId)) .limit(1) - if (orgExists.length === 0) { - throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`) + if (userStatsQuery.length === 0) { + throw new Error( + `No user stats record found for userId: ${userId}. User must be properly initialized before execution.` + ) + } + + if (!userStatsQuery[0].currentUsageLimit) { + throw new Error( + `Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}. User stats must be properly initialized.` + ) } - const orgLimit = await getOrgUsageLimit( - subscription.referenceId, - subscription.plan, - subscription.seats - ) - return orgLimit.limit + return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit)) } /** @@ -486,8 +540,9 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise { const subscription = await getHighestPrioritySubscription(userId) + const orgScoped = isOrgScopedSubscription(subscription, userId) let rawCost: number let refreshUserIds: string[] = [userId] - if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) { - const rows = await db - .select({ current: userStats.currentPeriodCost }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - if (rows.length === 0) return 0 - rawCost = toNumber(toDecimal(rows[0].current)) - } else { + if (orgScoped && subscription) { const teamMembers = await db .select({ userId: member.userId }) .from(member) @@ -625,6 +675,15 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise { const subscription = await getHighestPrioritySubscription(userId) - if (subscription && isOrgPlan(subscription.plan)) { + // Credits live on the entity that owns the subscription. For any + // org-scoped sub (including `pro_*` plans attached to an org), credits + // are read from `organization.creditBalance`. + if (isOrgScopedSubscription(subscription, userId) && subscription) { const orgRows = await db .select({ creditBalance: organization.creditBalance }) .from(organization) @@ -155,11 +161,11 @@ export async function deductFromCredits(userId: string, cost: number): Promise { try { const { basePrice } = getPlanPricing(plan) - const planBase = - entityType === 'organization' ? Number(basePrice) * (seats || 1) : Number(basePrice) + // planBase = basePrice × seats (or × 1 if no seat count). Matches + // Stripe's `price × quantity` billing for every paid non-enterprise + // plan. Keep consistent with `getOrgUsageLimit` and + // `updateOrganizationUsageLimit`. + const seatCount = seats ?? 1 + const planBase = Number(basePrice) * seatCount const creditBalanceNum = Number(creditBalance) const newLimit = planBase + creditBalanceNum @@ -128,7 +133,10 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise m.userId) + const personalStorageRows = await db + .select({ + userId: userStats.userId, + bytes: userStats.storageUsedBytes, + }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + const toTransfer = personalStorageRows.filter((r) => (r.bytes ?? 0) > 0) + const totalBytes = toTransfer.reduce((acc, r) => acc + (r.bytes ?? 0), 0) + + if (totalBytes > 0) { + await db + .update(organization) + .set({ + storageUsedBytes: sql`${organization.storageUsedBytes} + ${totalBytes}`, + }) + .where(eq(organization.id, organizationId)) + + await db + .update(userStats) + .set({ storageUsedBytes: 0 }) + .where( + inArray( + userStats.userId, + toTransfer.map((r) => r.userId) + ) + ) + + logger.info('Transferred personal storage bytes to org pool during sync', { + organizationId, + subscriptionId: subscription.id, + memberCount: toTransfer.length, + totalBytes, + }) + } + } catch (storageError) { + logger.error('Failed to transfer personal storage to org pool', { + organizationId, + subscriptionId: subscription.id, + error: storageError, + }) + } + } } } } catch (error) { diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 1f9a8780451..8b38f0a359a 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -16,7 +16,7 @@ import { import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' -import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers' +import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' @@ -414,7 +414,10 @@ export async function addUserToOrganization(params: AddMemberParams): Promise isOrgPlan(s.plan)) + // Any paid sub attached to an org the user is still a member + // of should prevent restoring their personal Pro — they're still + // covered by the org's plan, whatever its name. + hasAnyPaidTeam = orgPaidSubs.some((s) => isPaid(s.plan)) } if (!hasAnyPaidTeam) { diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts index 0031bf3dffe..9d817545e97 100644 --- a/apps/sim/lib/billing/plan-helpers.ts +++ b/apps/sim/lib/billing/plan-helpers.ts @@ -42,6 +42,24 @@ export function isPaid(plan: string | null | undefined): boolean { return isPro(plan) || isTeam(plan) || isEnterprise(plan) } +/** + * True when the plan **name** is a team/enterprise plan. + * + * WARNING: This is a plan-name check, NOT a scope check. It answers "is + * this a team- or enterprise-branded plan?" — which is NOT the same as + * "is this subscription attached to an organization?". + * + * A `pro_*` plan whose `referenceId` is an org id is org-scoped at the + * billing level even though `isOrgPlan` returns `false` for its plan name. + * + * For **scope decisions** (pooling usage, routing edits, choosing user vs + * organization context), use: + * - `isOrgScopedSubscription(sub, userId)` (pure, in `subscriptions/utils.ts`) + * - `isSubscriptionOrgScoped(sub)` (async DB lookup, in `core/billing.ts`) + * + * Keep using `isOrgPlan` only for plan-name semantics — e.g. display tier, + * "did the user purchase a team-branded plan?" during checkout, etc. + */ export function isOrgPlan(plan: string | null | undefined): boolean { return isTeam(plan) || isEnterprise(plan) } diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 6838a4b932d..36e05d33605 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -13,7 +13,8 @@ import { import { organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { getPlanTypeForLimits, isEnterprise, isFree, isOrgPlan } from '@/lib/billing/plan-helpers' +import { getPlanTypeForLimits, isEnterprise, isFree } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { getEnv } from '@/lib/core/config/env' import { isBillingEnabled } from '@/lib/core/config/feature-flags' @@ -78,7 +79,6 @@ export function getStorageLimitForPlan(plan: string, metadata?: any): number { */ export async function getUserStorageLimit(userId: string): Promise { try { - // Check if user is in a team/enterprise org const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) @@ -88,18 +88,10 @@ export async function getUserStorageLimit(userId: string): Promise { return limits.free } - if (!isOrgPlan(sub.plan)) { - const effectivePlan = getPlanTypeForLimits(sub.plan) - const limitByPlan: Record<'free' | 'pro' | 'team', number> = { - free: limits.free, - pro: limits.pro, - team: limits.team, - } - return limitByPlan[effectivePlan as 'free' | 'pro' | 'team'] ?? limits.free - } - - if (isOrgPlan(sub.plan)) { - // Get organization storage limit + // Any org-scoped subscription (team, enterprise, or `pro_*` attached to + // an org) uses pooled org-level storage. Custom limits come from the + // subscription metadata; otherwise use the team/enterprise default. + if (isOrgScopedSubscription(sub, userId)) { const orgRecord = await db .select({ metadata: subscription.metadata }) .from(subscription) @@ -113,11 +105,17 @@ export async function getUserStorageLimit(userId: string): Promise { } } - // Default for team/enterprise return isEnterprise(sub.plan) ? limits.enterpriseDefault : limits.team } - return limits.free + // Personally-scoped plans use the per-plan default storage cap. + const effectivePlan = getPlanTypeForLimits(sub.plan) + const limitByPlan: Record<'free' | 'pro' | 'team', number> = { + free: limits.free, + pro: limits.pro, + team: limits.team, + } + return limitByPlan[effectivePlan as 'free' | 'pro' | 'team'] ?? limits.free } catch (error) { logger.error('Error getting user storage limit:', error) return getStorageLimits().free @@ -130,11 +128,12 @@ export async function getUserStorageLimit(userId: string): Promise { */ export async function getUserStorageUsage(userId: string): Promise { try { - // Check if user is in a team/enterprise org const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - if (sub && isOrgPlan(sub.plan)) { + // Org-scoped subscriptions (including `pro_*` on org) share pooled + // `organization.storageUsedBytes`; personal plans use `userStats`. + if (isOrgScopedSubscription(sub, userId) && sub) { const orgRecord = await db .select({ storageUsedBytes: organization.storageUsedBytes }) .from(organization) @@ -144,7 +143,6 @@ export async function getUserStorageUsage(userId: string): Promise { return orgRecord.length > 0 ? orgRecord[0].storageUsedBytes || 0 : 0 } - // Free/Pro: Use user stats const stats = await db .select({ storageUsedBytes: userStats.storageUsedBytes }) .from(userStats) diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 4776e2ad558..dddb00488d7 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -8,7 +8,7 @@ import { db } from '@sim/db' import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' -import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' const logger = createLogger('StorageTracking') @@ -24,11 +24,12 @@ export async function incrementStorageUsage(userId: string, bytes: number): Prom } try { - // Check if user is in a team/enterprise org const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - if (sub && isOrgPlan(sub.plan)) { + // Org-scoped subscriptions (including `pro_*` on org) pool storage at + // the org level; personal plans track per-user. + if (isOrgScopedSubscription(sub, userId) && sub) { await db .update(organization) .set({ @@ -38,7 +39,6 @@ export async function incrementStorageUsage(userId: string, bytes: number): Prom logger.info(`Incremented org storage: ${bytes} bytes for org ${sub.referenceId}`) } else { - // Update user stats storage await db .update(userStats) .set({ @@ -65,11 +65,10 @@ export async function decrementStorageUsage(userId: string, bytes: number): Prom } try { - // Check if user is in a team/enterprise org const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - if (sub && isOrgPlan(sub.plan)) { + if (isOrgScopedSubscription(sub, userId) && sub) { await db .update(organization) .set({ @@ -79,7 +78,6 @@ export async function decrementStorageUsage(userId: string, bytes: number): Prom logger.info(`Decremented org storage: ${bytes} bytes for org ${sub.referenceId}`) } else { - // Update user stats storage await db .update(userStats) .set({ diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index 078bc77294d..29f48acb707 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -93,7 +93,10 @@ export function getEffectiveSeats(subscription: any): number { return 0 } - if (isTeam(subscription.plan)) { + // Team plans and `pro_*` plans attached to an organization both expose + // licensed seats via the `seats` column on the Stripe subscription. + // Personally-scoped `pro_*` subs have no seat concept, so they return 0. + if (isTeam(subscription.plan) || isPro(subscription.plan)) { return subscription.seats ?? 0 } @@ -109,9 +112,34 @@ export function checkTeamPlan(subscription: any): boolean { } /** - * Get the minimum usage limit for an individual user (used for validation) - * Only applicable for plans with individual limits (Free/Pro) - * Team and Enterprise plans use organization-level limits instead + * Returns true if the subscription's `referenceId` points at an organization + * (i.e. it is not the caller's own `userId`). + * + * Prefer this over plan-name checks (`isOrgPlan`, `isTeam`) when deciding + * whether reads/writes of the usage limit should be routed through the + * organization or the user. A subscription with plan `pro_6000` whose + * `referenceId` is an org id is org-scoped and must be treated as such, + * even though `isTeam`/`isOrgPlan` return false for its plan name. + * + * Callers should pass the user id whose perspective is being evaluated + * (normally the authenticated user or the billed-account user). + */ +export function isOrgScopedSubscription( + subscription: { referenceId?: string | null } | null | undefined, + userId: string +): boolean { + if (!subscription?.referenceId) return false + return subscription.referenceId !== userId +} + +/** + * Get the minimum usage limit for an individual user (used for validation). + * + * Callers should only invoke this for **personally-scoped** subscriptions — + * any org-scoped subscription (team, enterprise, or `pro_*` attached to an + * organization) uses the organization-level limit instead. Callers are + * responsible for gating with `isOrgScopedSubscription` before calling. + * * @param subscription The subscription object * @returns The per-user minimum limit in dollars */ @@ -127,9 +155,6 @@ export function getPerUserMinimumLimit(subscription: any): number { } if (isOrgPlan(subscription.plan)) { - // Team and Enterprise don't have individual limits - they use organization limits - // This function should not be called for these plans - // Returning 0 to indicate no individual minimum return 0 } diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 8e59b429f3e..9234f0e570f 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -8,16 +8,11 @@ import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/b import { calculateSubscriptionOverage, getPlanPricing } from '@/lib/billing/core/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { - getPlanTierDollars, - isEnterprise, - isFree, - isPaid, - isTeam, -} from '@/lib/billing/plan-helpers' +import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' import { hasUsableSubscriptionAccess, + isOrgScopedSubscription, USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' import { env } from '@/lib/core/config/env' @@ -128,10 +123,14 @@ export async function checkAndBillOverageThreshold(userId: string): Promise = [] + const orgScoped = await isSubscriptionOrgScoped(sub) - if (isOrgPlan(sub.plan)) { - // For team/enterprise, notify all owners and admins + if (orgScoped) { const members = await db .select({ userId: member.userId, @@ -276,7 +278,6 @@ async function sendPaymentFailureEmails( .from(member) .where(eq(member.organizationId, sub.referenceId)) - // Get owner/admin user details const ownerAdminIds = members .filter((m) => m.role === 'owner' || m.role === 'admin') .map((m) => m.userId) @@ -290,7 +291,6 @@ async function sendPaymentFailureEmails( usersToNotify = users.filter((u) => u.email && quickValidateEmail(u.email).isValid) } } else { - // For individual plans, notify the user const users = await db .select({ email: user.email, name: user.name }) .from(user) @@ -343,15 +343,17 @@ async function sendPaymentFailureEmails( } /** - * Get total billed overage for a subscription, handling team vs individual plans - * For team plans: sums billedOverageThisPeriod across all members - * For other plans: gets billedOverageThisPeriod for the user + * Get total billed overage for a subscription, handling org-scoped vs + * personally-scoped plans. + * - Org-scoped (team, enterprise, or `pro_*` attached to an org): + * stored on the org owner's `userStats.billedOverageThisPeriod`. + * - Personally-scoped: the user's own `billedOverageThisPeriod`. */ export async function getBilledOverageForSubscription(sub: { plan: string | null referenceId: string }): Promise { - if (isTeam(sub.plan)) { + if (await isSubscriptionOrgScoped(sub)) { const ownerRows = await db .select({ userId: member.userId }) .from(member) @@ -386,7 +388,7 @@ export async function getBilledOverageForSubscription(sub: { } export async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) { - if (isOrgPlan(sub.plan)) { + if (await isSubscriptionOrgScoped(sub)) { const membersRows = await db .select({ userId: member.userId }) .from(member) @@ -432,23 +434,45 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe .where(eq(userStats.userId, sub.referenceId)) .limit(1) if (currentStats.length > 0) { - // For Pro plans, combine current + snapshot for lastPeriodCost, then clear both const current = Number.parseFloat(currentStats[0].current?.toString() || '0') const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0') - const totalLastPeriod = (current + snapshot).toString() const currentCopilot = currentStats[0].currentCopilot || '0' - await db - .update(userStats) - .set({ - lastPeriodCost: totalLastPeriod, - lastPeriodCopilotCost: currentCopilot, - currentPeriodCost: '0', - currentPeriodCopilotCost: '0', - proPeriodCostSnapshot: '0', // Clear snapshot at period end - billedOverageThisPeriod: '0', // Clear threshold billing tracker at period end - }) - .where(eq(userStats.userId, sub.referenceId)) + // Attribution rule (mirrors `calculateSubscriptionOverage` personal + // Pro branch): if the user joined a paid org mid-cycle, + // `proPeriodCostSnapshot` holds the pre-join usage that was billed + // on this invoice, while `currentPeriodCost` is post-join usage + // that's still being billed by the org. Preserve `currentPeriodCost` + // (and `currentPeriodCopilotCost`) so the org's next cycle-close + // captures them; only retire the snapshot and personal-billing + // trackers here. `lastPeriodCopilotCost` is cleared because the + // invitation-accept flow resets `currentPeriodCopilotCost` (without + // snapshotting it), so the personal Pro's final period had no + // trackable copilot usage to report as "last period". + if (snapshot > 0) { + await db + .update(userStats) + .set({ + lastPeriodCost: snapshot.toString(), + lastPeriodCopilotCost: '0', + proPeriodCostSnapshot: '0', + billedOverageThisPeriod: '0', + }) + .where(eq(userStats.userId, sub.referenceId)) + } else { + const totalLastPeriod = (current + snapshot).toString() + await db + .update(userStats) + .set({ + lastPeriodCost: totalLastPeriod, + lastPeriodCopilotCost: currentCopilot, + currentPeriodCost: '0', + currentPeriodCopilotCost: '0', + proPeriodCostSnapshot: '0', + billedOverageThisPeriod: '0', + }) + .where(eq(userStats.userId, sub.referenceId)) + } } } } @@ -487,7 +511,19 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise 0) { const sub = subscription[0] - const { balance: newCreditBalance } = await getCreditBalance(entityId) + // `getCreditBalance` is keyed by user id. For org-scoped entities we + // read from the owner's perspective so it traverses through to + // `organization.creditBalance`. + let balanceUserId = entityId + if (entityType === 'organization') { + const ownerRow = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, entityId), eq(member.role, 'owner'))) + .limit(1) + balanceUserId = ownerRow[0]?.userId ?? (purchasedBy || entityId) + } + const { balance: newCreditBalance } = await getCreditBalance(balanceUserId) await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance) } @@ -499,11 +535,12 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise = [] if (entityType === 'organization') { @@ -578,10 +615,11 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { } const { sub } = resolvedInvoice + const subIsOrgScoped = await isSubscriptionOrgScoped(sub) // Only reset usage here if the tenant was previously blocked; otherwise invoice.created already reset it let wasBlocked = false - if (isOrgPlan(sub.plan)) { + if (subIsOrgScoped) { const membersRows = await db .select({ userId: member.userId }) .from(member) @@ -611,7 +649,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0 if (shouldUnblock) { - if (isOrgPlan(sub.plan)) { + if (subIsOrgScoped) { await unblockOrgMembers(sub.referenceId, 'payment_failed') } else { await db @@ -695,9 +733,9 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { stripeSubscriptionId, }) - if (isOrgPlan(sub.plan)) { + if (await isSubscriptionOrgScoped(sub)) { const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') - logger.info('Blocked team/enterprise members due to payment failure', { + logger.info('Blocked org members due to payment failure', { invoiceType: invoiceType ?? 'subscription', memberCount, organizationId: sub.referenceId, @@ -794,9 +832,20 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { // Apply credits to reduce overage at end of cycle let creditsApplied = 0 if (remainingOverage > 0) { - const entityType = isOrgPlan(sub.plan) ? 'organization' : 'user' + const entityType = (await isSubscriptionOrgScoped(sub)) ? 'organization' : 'user' const entityId = sub.referenceId - const { balance: creditBalance } = await getCreditBalance(entityId) + // `getCreditBalance` is keyed by user id; when we need the org balance + // we call it on the org owner to read the pooled org credits. + let balanceUserId = entityId + if (entityType === 'organization') { + const ownerRow = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, entityId), eq(member.role, 'owner'))) + .limit(1) + balanceUserId = ownerRow[0]?.userId ?? entityId + } + const { balance: creditBalance } = await getCreditBalance(balanceUserId) if (creditBalance > 0) { creditsApplied = Math.min(creditBalance, remainingOverage) diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 8c261a6f458..f4097b09f18 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -2,11 +2,11 @@ import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, ne } from 'drizzle-orm' -import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' +import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { hasPaidSubscription } from '@/lib/billing/core/subscription' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' -import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' +import { isEnterprise, isPaid, isPro } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { @@ -330,7 +330,11 @@ export async function handleSubscriptionDeleted(subscription: { let organizationDeleted = false let membersSynced = 0 - if (isTeam(subscription.plan)) { + // Route cleanup by subscription scope (where the referenceId points), + // not plan name. A `pro_*` attached to an org needs org-level cleanup, + // not the per-user sync path (which would treat `referenceId` as a user + // id and silently do nothing for members). + if (await isSubscriptionOrgScoped(subscription)) { const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) restoredProCount = cleanup.restoredProCount membersSynced = cleanup.membersSynced diff --git a/apps/sim/lib/copilot/request/tools/billing.ts b/apps/sim/lib/copilot/request/tools/billing.ts index 2f4b0e2d7ac..43d51d621f1 100644 --- a/apps/sim/lib/copilot/request/tools/billing.ts +++ b/apps/sim/lib/copilot/request/tools/billing.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' -import { isPaid } from '@/lib/billing/plan-helpers' +import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -29,14 +30,25 @@ export async function handleBillingLimitResponse( execContext: ExecutionContext, options: OrchestratorOptions ): Promise { - let action = 'upgrade_plan' + let action: 'upgrade_plan' | 'increase_limit' = 'upgrade_plan' let message = "You've reached your usage limit. Please upgrade your plan to continue." try { const sub = await getHighestPrioritySubscription(userId) if (sub && isPaid(sub.plan)) { + // Paid subs use the existing `increase_limit` action so the UI + // (`UsageUpgradeDisplay`) renders its standard button. The message + // text does the work of clarifying the action when the user can't + // actually self-serve the limit change. action = 'increase_limit' - message = - "You've reached your usage limit for this billing period. Please increase your usage limit to continue." + const orgScoped = isOrgScopedSubscription(sub, userId) + if (orgScoped) { + message = isEnterprise(sub.plan) + ? "You've reached your organization's usage limit for this billing period. Only an organization admin or Sim support can raise an enterprise limit — reach out to them to continue." + : "You've reached your organization's usage limit for this billing period. Only an organization owner or admin can raise the limit — please ask them to update it from the team billing settings." + } else { + message = + "You've reached your usage limit for this billing period. Please increase your usage limit from billing settings to continue." + } } } catch { logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan') diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.ts index 50186ce9755..b737bc7ffc8 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { toError } from '@/lib/core/utils/helpers' import { createStorageAdapter, type RateLimitStorageAdapter } from './storage' import { @@ -42,7 +42,11 @@ export class RateLimiter { private getRateLimitKey(userId: string, subscription: SubscriptionInfo | null): string { if (!subscription) return userId - if (isOrgPlan(subscription.plan) && subscription.referenceId !== userId) { + // Pool rate limits at the org level whenever the subscription is + // attached to an organization — this covers team/enterprise AND + // `pro_*` plans that have been transferred to an org (plan name alone + // is not a reliable signal of scope). + if (isOrgScopedSubscription(subscription, userId)) { return subscription.referenceId } diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index afa70de6ea5..df8398ac01b 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -16,7 +16,6 @@ import { maybeSendUsageThresholdEmail, } from '@/lib/billing/core/usage' import { type ModelUsageMetadata, recordUsage } from '@/lib/billing/core/usage-log' -import { isOrgPlan } from '@/lib/billing/plan-helpers' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { redactApiKeys } from '@/lib/core/security/redaction' @@ -415,9 +414,14 @@ export class ExecutionLogger implements IExecutionLoggerService { const costDelta = costSummary.totalCost const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers') + const { isOrgScopedSubscription } = await import('@/lib/billing/subscriptions/utils') const planName = getDisplayPlanName(sub?.plan) - const scope: 'user' | 'organization' = - sub && isOrgPlan(sub.plan) ? 'organization' : 'user' + // Scope the threshold email by the subscription's referenceId, not + // its plan name. This correctly handles `pro_*` plans attached to + // an org: the 80% warning should go to org admins, not the user. + const scope: 'user' | 'organization' = isOrgScopedSubscription(sub, usr.id) + ? 'organization' + : 'user' if (scope === 'user') { const before = await checkUsageStatus(usr.id) From 2a4a417d717d35fd83a1d30f0cfd3e2880f39d4e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 18:41:14 -0700 Subject: [PATCH 02/11] fix tests --- apps/sim/lib/billing/organization.ts | 36 ++++++++++++------- .../sim/lib/billing/webhooks/invoices.test.ts | 1 + 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index 947307e2e5f..e71a7748acc 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -335,29 +335,39 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData // on the next subscription event for any previously-joined // members whose bytes weren't transferred. Safe to re-run: once // transferred the user row is 0, so subsequent passes no-op. + // + // Race note: concurrent `incrementStorageUsage` / + // `decrementStorageUsage` on the same rows could otherwise slip + // between our snapshot SELECT and the zeroing UPDATE, wiping + // bytes or corrupting the aggregate. We open a transaction and + // take row-level write locks via `SELECT ... FOR UPDATE` so + // concurrent writes on these user rows block until we commit. if (isPaid(subscription.plan)) { try { const memberIds = members.map((m) => m.userId) - const personalStorageRows = await db - .select({ - userId: userStats.userId, - bytes: userStats.storageUsedBytes, - }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) + await db.transaction(async (tx) => { + const personalStorageRows = await tx + .select({ + userId: userStats.userId, + bytes: userStats.storageUsedBytes, + }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + .for('update') - const toTransfer = personalStorageRows.filter((r) => (r.bytes ?? 0) > 0) - const totalBytes = toTransfer.reduce((acc, r) => acc + (r.bytes ?? 0), 0) + const toTransfer = personalStorageRows.filter((r) => (r.bytes ?? 0) > 0) + const totalBytes = toTransfer.reduce((acc, r) => acc + (r.bytes ?? 0), 0) - if (totalBytes > 0) { - await db + if (totalBytes === 0) return + + await tx .update(organization) .set({ storageUsedBytes: sql`${organization.storageUsedBytes} + ${totalBytes}`, }) .where(eq(organization.id, organizationId)) - await db + await tx .update(userStats) .set({ storageUsedBytes: 0 }) .where( @@ -373,7 +383,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData memberCount: toTransfer.length, totalBytes, }) - } + }) } catch (storageError) { logger.error('Failed to transfer personal storage to org pool', { organizationId, diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index 63b14c04f83..670ba91415c 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -92,6 +92,7 @@ vi.mock('@/components/emails', () => ({ vi.mock('@/lib/billing/core/billing', () => ({ calculateSubscriptionOverage: vi.fn(), + isSubscriptionOrgScoped: vi.fn().mockResolvedValue(true), })) vi.mock('@/lib/billing/credits/balance', () => ({ From c1e39acf76744dbbe2b288e564f68a4f5367ab53 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 19:01:42 -0700 Subject: [PATCH 03/11] address more comments --- .../[id]/invitations/[invitationId]/route.ts | 10 +++++++++ .../subscription/subscription-permissions.ts | 21 ++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index bb8941644d0..523ac144614 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -422,10 +422,20 @@ export async function PUT( // this transfer, pre-join bytes would be orphaned on the // user's row and subsequent decrements (deleting a pre-join // file after joining) would wrongly reduce the org pool. + // + // `.for('update')` acquires a row-level write lock on the + // user's `user_stats` row so a concurrent + // `incrementStorageUsage`/`decrementStorageUsage` (from + // another tab, a scheduled run, an API-key writer, etc.) + // blocks until this transaction commits — otherwise Postgres + // READ COMMITTED would let a write land between the snapshot + // SELECT and the zero UPDATE, silently dropping those bytes. + // Mirrors the bulk version in `syncSubscriptionUsageLimits`. const storageRows = await tx .select({ storageUsedBytes: userStats.storageUsedBytes }) .from(userStats) .where(eq(userStats.userId, userId)) + .for('update') .limit(1) const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0 diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts index 0e9e861eba6..f571ab00ab1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts @@ -58,8 +58,14 @@ export function getSubscriptionPermissions( canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo, canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo, showTeamMemberView: orgMemberOnly, + // Personal Pro can upgrade to team/enterprise. Any org admin/owner on + // a non-enterprise plan can upgrade to enterprise — covers team admins + // AND admins of `pro_*` plans attached to an org (previously missed by + // the narrower `isTeam && isTeamAdmin` check, which left pro-on-org + // admins with no upgrade path even though `getVisiblePlans` listed + // enterprise for them). showUpgradePlans: - (isFree || (isPro && !isOrgScoped) || (isTeam && isTeamAdmin)) && !isEnterprise, + (isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise, isEnterpriseMember, canViewUsageInfo, } @@ -70,7 +76,7 @@ export function getVisiblePlans( userRole: UserRole ): ('pro' | 'team' | 'enterprise')[] { const plans: ('pro' | 'team' | 'enterprise')[] = [] - const { isFree, isPro, isTeam, isOrgScoped } = subscription + const { isFree, isPro, isEnterprise, isOrgScoped } = subscription const { isTeamAdmin } = userRole // Free users see all plans @@ -81,13 +87,14 @@ export function getVisiblePlans( else if (isPro && !isOrgScoped) { plans.push('team', 'enterprise') } - // Team/org owners/admins: only enterprise (already on a team-level plan) - else if (isOrgScoped && isTeamAdmin && !isTeam) { - plans.push('enterprise') - } else if (isTeam && isTeamAdmin) { + // Org admin/owner on a non-enterprise plan: enterprise is the only + // remaining upgrade. Covers team admins and `pro_*`-on-org admins. + // Explicitly excludes enterprise admins (already on the top tier) so + // this stays consistent with `showUpgradePlans`. + else if (isOrgScoped && isTeamAdmin && !isEnterprise) { plans.push('enterprise') } - // Team/org members, Enterprise users see no plans + // Org members, Enterprise users see no plans return plans } From 2bb951d7a1571410bd8696bbe3536ba416091970 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 19:55:04 -0700 Subject: [PATCH 04/11] progress --- apps/sim/app/api/billing/switch-plan/route.ts | 3 - .../[id]/invitations/[invitationId]/route.ts | 28 ++---- apps/sim/app/api/v1/admin/credits/route.ts | 4 +- .../api/v1/admin/users/[id]/billing/route.ts | 3 - .../subscription/subscription-permissions.ts | 26 +----- .../usage-indicator/usage-indicator.tsx | 9 -- apps/sim/lib/auth/auth.ts | 6 -- .../lib/billing/calculations/usage-monitor.ts | 56 ++++------- apps/sim/lib/billing/client/upgrade.ts | 3 - apps/sim/lib/billing/core/billing.ts | 53 ++++------- apps/sim/lib/billing/core/organization.ts | 44 ++------- apps/sim/lib/billing/core/subscription.ts | 11 +-- apps/sim/lib/billing/core/usage.ts | 92 +++++-------------- apps/sim/lib/billing/credits/balance.ts | 47 ++++++---- apps/sim/lib/billing/credits/purchase.ts | 10 +- apps/sim/lib/billing/organization.ts | 24 ++--- .../lib/billing/organizations/membership.ts | 7 +- apps/sim/lib/billing/plan-helpers.ts | 21 +---- apps/sim/lib/billing/storage/limits.ts | 7 +- apps/sim/lib/billing/storage/tracking.ts | 3 +- apps/sim/lib/billing/subscriptions/utils.ts | 20 ++-- apps/sim/lib/billing/threshold-billing.ts | 11 +-- .../lib/billing/validation/seat-management.ts | 8 +- apps/sim/lib/billing/webhooks/invoices.ts | 54 ++--------- apps/sim/lib/billing/webhooks/subscription.ts | 4 - .../sim/lib/core/rate-limiter/rate-limiter.ts | 4 - apps/sim/lib/logs/execution/logger.ts | 3 - 27 files changed, 154 insertions(+), 407 deletions(-) diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index e68911ad29a..4c763caa8c9 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -94,9 +94,6 @@ export async function POST(request: NextRequest) { ) } - // Any subscription whose referenceId is an organization (team, - // enterprise, or `pro_*` attached to an org) requires org admin/owner - // to change the plan. if (isOrgScopedSubscription(sub, userId)) { const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId) if (!hasPermission) { diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index 523ac144614..fc07c9b6221 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -356,9 +356,6 @@ export async function PUT( .limit(1) const orgSub = orgSubs[0] - // Any paid subscription attached to the org triggers the - // "snapshot my personal Pro usage and cancel it" flow — includes - // `pro_*` plans transferred to the org, not just team/enterprise. const orgIsPaid = orgSub && isPaid(orgSub.plan) if (orgIsPaid) { @@ -414,23 +411,14 @@ export async function PUT( } } - // Transfer the joining user's accumulated personal storage - // bytes into the organization's pool. After this point - // `isOrgScopedSubscription` returns true for the user, so - // `getUserStorageUsage`/`incrementStorageUsage`/`decrementStorageUsage` - // all route through `organization.storageUsedBytes`. Without - // this transfer, pre-join bytes would be orphaned on the - // user's row and subsequent decrements (deleting a pre-join - // file after joining) would wrongly reduce the org pool. - // - // `.for('update')` acquires a row-level write lock on the - // user's `user_stats` row so a concurrent - // `incrementStorageUsage`/`decrementStorageUsage` (from - // another tab, a scheduled run, an API-key writer, etc.) - // blocks until this transaction commits — otherwise Postgres - // READ COMMITTED would let a write land between the snapshot - // SELECT and the zero UPDATE, silently dropping those bytes. - // Mirrors the bulk version in `syncSubscriptionUsageLimits`. + // Transfer the joining user's pre-join storage bytes into + // the org pool — after this point storage reads/writes route + // through `organization.storageUsedBytes`, so bytes left on + // `user_stats` would be orphaned (and a later decrement from + // deleting a pre-join file would wrongly reduce the org + // pool). `.for('update')` row-locks `user_stats` so a + // concurrent increment/decrement can't land between the + // SELECT and the zero UPDATE and get silently dropped. const storageRows = await tx .select({ storageUsedBytes: userStats.storageUsedBytes }) .from(userStats) diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index 2ed84a4e2ce..41bcdefd063 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -111,9 +111,7 @@ export const POST = withAdminAuth(async (request) => { const plan = userSubscription.plan let seats: number | null = null - // Route admin credits to the subscription's actual entity. Any sub whose - // `referenceId` is an org gets credited to the org pool — including - // `pro_*` plans transferred to an org. + // Route admin credits to the subscription's entity (org if org-scoped). if (isOrgScopedSubscription(userSubscription, resolvedUserId)) { entityType = 'organization' entityId = userSubscription.referenceId diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 2130036679a..ecc10aab242 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -155,9 +155,6 @@ export const PATCH = withAdminAuthParams(async (request, context) = .limit(1) const userSubscription = await getHighestPrioritySubscription(userId) - // True for any user whose effective subscription is attached to an org - // (team, enterprise, or `pro_*` transferred to an org). They have no - // individual usage limit — the org cap governs. const isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId) const [orgMembership] = await db diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts index f571ab00ab1..bf07d457132 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription-permissions.ts @@ -39,9 +39,8 @@ export function getSubscriptionPermissions( const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription const { isTeamAdmin } = userRole - // "Org-scoped non-admin" collapses all the "team member" behaviors - // (hidden edit, hidden cancel, no upgrade plans, pooled view, etc.). - // This includes members of `pro_*` orgs that aren't admins/owners. + // Non-admin org members see the "team member" view: no edit / no cancel + // / no upgrade, pooled usage display. const orgMemberOnly = isOrgScoped && !isTeamAdmin const orgAdminOrSolo = !isOrgScoped || isTeamAdmin @@ -53,17 +52,9 @@ export function getSubscriptionPermissions( canUpgradeToTeam: isFree || (isPro && !isOrgScoped), canViewEnterprise: !isEnterprise && !orgMemberOnly, canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise, - // Edit the limit when: paid plan (not free, not enterprise) AND either - // personally-scoped or acting as an org admin/owner. canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo, canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo, showTeamMemberView: orgMemberOnly, - // Personal Pro can upgrade to team/enterprise. Any org admin/owner on - // a non-enterprise plan can upgrade to enterprise — covers team admins - // AND admins of `pro_*` plans attached to an org (previously missed by - // the narrower `isTeam && isTeamAdmin` check, which left pro-on-org - // admins with no upgrade path even though `getVisiblePlans` listed - // enterprise for them). showUpgradePlans: (isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise, isEnterpriseMember, @@ -79,22 +70,13 @@ export function getVisiblePlans( const { isFree, isPro, isEnterprise, isOrgScoped } = subscription const { isTeamAdmin } = userRole - // Free users see all plans if (isFree) { plans.push('pro', 'team', 'enterprise') - } - // Personally-scoped Pro: can upgrade to team or enterprise - else if (isPro && !isOrgScoped) { + } else if (isPro && !isOrgScoped) { plans.push('team', 'enterprise') - } - // Org admin/owner on a non-enterprise plan: enterprise is the only - // remaining upgrade. Covers team admins and `pro_*`-on-org admins. - // Explicitly excludes enterprise admins (already on the top tier) so - // this stays consistent with `showUpgradePlans`. - else if (isOrgScoped && isTeamAdmin && !isEnterprise) { + } else if (isOrgScoped && isTeamAdmin && !isEnterprise) { plans.push('enterprise') } - // Org members, Enterprise users see no plans return plans } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx index 5bd0434324d..ae72152a8ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx @@ -335,20 +335,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const contextMenuItems = useMemo( () => ({ - // Set limit: anyone who can manage billing on a paid non-enterprise - // plan. `userCanManageBilling` already enforces owner/admin for - // org-scoped subs (including `pro_*` attached to an org), so members - // of an org don't see this. showSetLimit: userCanManageBilling && !isFree && !isEnterprise, - // Upgrade to Pro: Only for free users showUpgradeToPro: isFree, - // Upgrade to Team: Free users and Pro users with billing permission showUpgradeToTeam: isFree || (isPro && userCanManageBilling), - // Manage seats: Only for Team admins showManageSeats: isTeam && userCanManageBilling, - // Upgrade to Enterprise: Only for Team admins (not free, not pro, not enterprise) showUpgradeToEnterprise: isTeam && userCanManageBilling, - // Contact support: Only for Enterprise admins showContactSupport: isEnterprise && userCanManageBilling, onSetLimit: handleSetLimit, onUpgradeToPro: handleUpgradeToPro, diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 20a78fafc78..4e373223caf 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -2905,8 +2905,6 @@ export const auth = betterAuth({ ) } - // Persist the Stripe-resolved plan name to our DB row before - // any downstream work so subsequent reads see the fresh plan. await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe) const subscriptionForOrg = { @@ -2987,10 +2985,6 @@ export const auth = betterAuth({ ) } - // Sync the DB's `plan` column to whatever Stripe currently - // says. better-auth's upgrade flow updates Stripe price, - // seats, and referenceId, but historically left `plan` - // stale (see `pro_6000` attached to an org in prod). await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe) const subscriptionForOrg = { diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 76fd7eb41df..41f82450789 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -27,11 +27,9 @@ interface UsageData { currentUsage: number limit: number /** - * Whether the returned `currentUsage`/`limit` represent the user's - * individual slice (`'user'`) or the organization's pooled total and cap - * (`'organization'`). When `isExceeded` is driven by an org pool check, - * the pooled values are surfaced here so downstream error messages are - * accurate. + * Whether the returned values are this user's individual slice or the + * organization's pooled total/cap. When an org pool is the blocker, + * the pooled values are surfaced here so error messages reflect it. */ scope: 'user' | 'organization' /** Present only when `scope === 'organization'`. */ @@ -47,9 +45,7 @@ export async function checkUsageStatus( preloadedSubscription?: HighestPrioritySubscription ): Promise { try { - // If billing is disabled, always return permissive limits if (!isBillingEnabled) { - // Get actual usage from the database for display purposes const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) const currentUsage = statsRecords.length > 0 @@ -67,20 +63,14 @@ export async function checkUsageStatus( } } - // Resolve the highest-priority subscription once so every branch below - // agrees on scope. The caller may have already loaded it. const sub = preloadedSubscription !== undefined ? preloadedSubscription : await getHighestPrioritySubscription(userId) - // Primary limit from user_stats or org (routed by scope). const limit = await getUserUsageLimit(userId, sub) logger.info('Using stored usage limit', { userId, limit }) - // Usage baseline. - // Org-scoped: pooled sum across all org members (with pooled refresh). - // Personal: user's own currentPeriodCost (with personal refresh). const subIsOrgScoped = isOrgScopedSubscription(sub, userId) let currentUsage = 0 @@ -114,7 +104,7 @@ export async function checkUsageStatus( periodStart: sub.periodStart, periodEnd: sub.periodEnd ?? null, planDollars, - seats: sub.seats ?? 1, + seats: sub.seats || 1, }) pooled = Math.max(0, pooled - refresh) } @@ -122,7 +112,6 @@ export async function checkUsageStatus( } currentUsage = pooled } else { - // Personally-scoped: use this user's own row (defensive default 0). const statsRecords = await db .select() .from(userStats) @@ -161,11 +150,10 @@ export async function checkUsageStatus( currentUsage = Math.max(0, rawUsage - refresh) } - // Defense-in-depth: even when the user's priority sub is personal, they - // may still be a member of an org whose pool has blown its cap (e.g. - // billed-account scenarios). Enforce every entitled-org cap they belong - // to and override the returned values when one is actually blocking — - // that way the error message surfaces the org number, not personal. + // Defense-in-depth: enforce every entitled org cap the user belongs + // to, even when their priority sub is personal. If a secondary org + // pool is blocking, surface its numbers so the error message does + // not quote personal usage while enforcing an org cap. try { const memberships = await db .select({ organizationId: member.organizationId }) @@ -173,12 +161,11 @@ export async function checkUsageStatus( .where(eq(member.userId, userId)) for (const m of memberships) { - // Skip the org the primary sub is already keyed to; we've already - // computed pooled usage against its cap above. + // Already handled above as the primary org. if (subIsOrgScoped && sub && sub.referenceId === m.organizationId) continue - // Pull the full org subscription row — refresh math below needs - // THAT org's plan/period/seats, not the caller's primary sub. + // Refresh math below needs THIS org's plan/period/seats, not + // the caller's primary sub (which may be a personal Pro). const [orgSub] = await db .select({ plan: subscription.plan, @@ -221,9 +208,6 @@ export async function checkUsageStatus( } } - // Refresh is driven by the org's OWN subscription period, plan - // dollars, and seats — not the caller's primary sub (which may be - // a personal Pro in this branch). if (isPaid(orgSub.plan) && orgSub.periodStart) { const planDollars = getPlanTierDollars(orgSub.plan) if (planDollars > 0) { @@ -233,7 +217,7 @@ export async function checkUsageStatus( periodStart: orgSub.periodStart, periodEnd: orgSub.periodEnd ?? null, planDollars, - seats: orgSub.seats ?? 1, + seats: orgSub.seats || 1, }) pooledUsage = Math.max(0, pooledUsage - orgRefreshDeduction) } @@ -295,9 +279,9 @@ export async function checkUsageStatus( return { percentUsed: 100, isWarning: false, - isExceeded: true, // Block execution when we can't determine status + isExceeded: true, currentUsage: 0, - limit: 0, // Zero limit forces blocking + limit: 0, scope: 'user', organizationId: null, } @@ -310,7 +294,6 @@ export async function checkUsageStatus( */ export async function checkAndNotifyUsage(userId: string): Promise { try { - // Skip usage notifications if billing is disabled if (!isBillingEnabled) { return } @@ -318,14 +301,12 @@ export async function checkAndNotifyUsage(userId: string): Promise { const usageData = await checkUsageStatus(userId) if (usageData.isExceeded) { - // User has exceeded their limit logger.warn('User has exceeded usage limits', { userId, usage: usageData.currentUsage, limit: usageData.limit, }) - // Dispatch event to show a UI notification if (typeof window !== 'undefined') { window.dispatchEvent( new CustomEvent('usage-exceeded', { @@ -334,7 +315,6 @@ export async function checkAndNotifyUsage(userId: string): Promise { ) } } else if (usageData.isWarning) { - // User is approaching their limit logger.info('User approaching usage limits', { userId, usage: usageData.currentUsage, @@ -342,7 +322,6 @@ export async function checkAndNotifyUsage(userId: string): Promise { percent: usageData.percentUsed, }) - // Dispatch event to show a UI notification if (typeof window !== 'undefined') { window.dispatchEvent( new CustomEvent('usage-warning', { @@ -383,7 +362,6 @@ export async function checkServerSideUsageLimits( logger.info('Server-side checking usage limits for user', { userId }) - // Check user's own blocked status const stats = await db .select({ blocked: userStats.billingBlocked, @@ -413,14 +391,12 @@ export async function checkServerSideUsageLimits( } } - // Check if user is in an org where the owner is blocked const memberships = await db .select({ organizationId: member.organizationId }) .from(member) .where(eq(member.userId, userId)) for (const m of memberships) { - // Find the owner of this org const owners = await db .select({ userId: member.userId }) .from(member) @@ -479,9 +455,9 @@ export async function checkServerSideUsageLimits( }) return { - isExceeded: true, // Block execution when we can't determine limits + isExceeded: true, currentUsage: 0, - limit: 0, // Zero limit forces blocking + limit: 0, message: error instanceof Error && error.message.includes('No user stats record found') ? 'User account not properly initialized. Please contact support.' diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts index 560bd98bd3f..8baa555b0f5 100644 --- a/apps/sim/lib/billing/client/upgrade.ts +++ b/apps/sim/lib/billing/client/upgrade.ts @@ -65,9 +65,6 @@ export function useSubscriptionUpgrade() { ) if (existingOrg) { - // Check if this org already has ANY paid subscription — team, - // enterprise, or a `pro_*` that's been transferred to the org. - // Upgrading again would create a duplicate. const existingOrgSub = allSubscriptions.find( (sub: any) => hasPaidSubscriptionStatus(sub.status) && diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 742fa5ac7b9..f77093b9dea 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -168,7 +168,7 @@ export async function calculateSubscriptionOverage(sub: { periodStart: sub.periodStart, periodEnd: sub.periodEnd ?? null, planDollars, - seats: sub.seats ?? 1, + seats: sub.seats || 1, }) } @@ -177,9 +177,7 @@ export async function calculateSubscriptionOverage(sub: { totalUsageWithDepartedDecimal.minus(toDecimal(dailyRefreshDeduction)) ) const { basePrice } = getPlanPricing(sub.plan ?? '') - // Base = basePrice × seats (or × 1 when seats is null), mirroring - // Stripe's `price × quantity` for every paid non-enterprise plan. - const baseSubscriptionAmount = (sub.seats ?? 1) * basePrice + const baseSubscriptionAmount = (sub.seats || 1) * basePrice totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(baseSubscriptionAmount)) logger.info('Calculated org-scoped overage', { @@ -192,13 +190,11 @@ export async function calculateSubscriptionOverage(sub: { totalOverage: toNumber(totalOverageDecimal), }) } else if (isPro(sub.plan)) { - // Personal Pro finalization. Read this user's own row directly instead - // of going through `getUserUsageData` — that helper follows - // `getHighestPrioritySubscription`, which now prefers an org sub over - // a personal sub within the same tier. During the `cancelAtPeriodEnd` - // grace window (user has an active personal Pro AND is a member of a - // paid org), routing through the priority lookup would bill pooled - // org usage on the personal Pro's final invoice. + // Read user_stats directly (not via `getUserUsageData`). Priority + // lookup prefers org over personal within tier, so during a + // cancel-at-period-end grace window it would return pooled org usage + // instead of this user's personal period — overbilling the final + // personal Pro invoice. const [statsRow] = await db .select({ currentPeriodCost: userStats.currentPeriodCost, @@ -211,12 +207,10 @@ export async function calculateSubscriptionOverage(sub: { const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0 const snapshotUsage = statsRow ? toNumber(toDecimal(statsRow.proPeriodCostSnapshot)) : 0 - // Attribution rule: if the user joined a paid org mid-cycle, their - // pre-join usage is in `proPeriodCostSnapshot` and their post-join - // usage (still accumulating in `currentPeriodCost`) now belongs to the - // org pool. This personal-Pro invoice must only bill the pre-join - // portion, otherwise post-join org usage gets double-charged here and - // on the org's own invoice. + // If snapshot > 0 the user joined a paid org mid-cycle: pre-join + // usage sits in `proPeriodCostSnapshot`, post-join is still + // accumulating in `currentPeriodCost` and will be billed by the org. + // Bill only the snapshot here to avoid double-charging. const joinedOrgMidCycle = snapshotUsage > 0 const totalProUsageDecimal = joinedOrgMidCycle ? toDecimal(snapshotUsage) @@ -231,7 +225,6 @@ export async function calculateSubscriptionOverage(sub: { }) } - // Apply personal daily refresh for this sub's own period. let dailyRefreshDeduction = 0 const planDollars = getPlanTierDollars(sub.plan) if (planDollars > 0 && sub.periodStart) { @@ -261,8 +254,7 @@ export async function calculateSubscriptionOverage(sub: { totalOverage: toNumber(totalOverageDecimal), }) } else { - // Free plan or unknown plan type scoped to a user. Read personal row - // directly for the same reason as the Pro branch above. + // Free or unknown plan. Same direct-read rationale as the Pro branch. const [statsRow] = await db .select({ currentPeriodCost: userStats.currentPeriodCost }) .from(userStats) @@ -306,13 +298,7 @@ export async function getSimplifiedBillingSummary( isPro: boolean isTeam: boolean isEnterprise: boolean - /** - * True when the subscription is attached to an organization rather than - * the user. Includes `pro_*` plans that have been transferred to an org; - * prefer this over `isTeam` / `isEnterprise` for scope decisions (which - * API context to use, whether to pool usage, whether the limit is edited - * at the org level, etc.). - */ + /** True when the subscription's `referenceId` is an organization id. */ isOrgScoped: boolean /** Present when `isOrgScoped` is true. */ organizationId: string | null @@ -346,16 +332,12 @@ export async function getSimplifiedBillingSummary( getUserUsageData(userId), ]) - // Determine subscription type flags const plan = subscription?.plan || 'free' const hasPaidEntitlement = hasPaidSubscriptionStatus(subscription?.status) const planIsPaid = hasPaidEntitlement && isPaid(plan) const planIsPro = hasPaidEntitlement && isPro(plan) const planIsTeam = hasPaidEntitlement && isTeam(plan) const planIsEnterprise = hasPaidEntitlement && isEnterprise(plan) - // Source of truth for "is this subscription at org level?" is the - // subscription's referenceId, not its plan name. A `pro_6000` attached - // to an org is org-scoped even though `isTeam` would return false. const orgScoped = isOrgScopedSubscription(subscription, userId) const subscriptionOrgId = orgScoped && subscription ? subscription.referenceId : null @@ -365,9 +347,9 @@ export async function getSimplifiedBillingSummary( return getDefaultBillingSummary('organization') } - // Pool usage/copilot across all org members in a single query — do - // NOT call `getUserUsageData` per member because that now returns the - // entire pool for org-scoped subs, which would N-times-count. + // Pool usage/copilot across all members in one query. Must not use + // `getUserUsageData` per-member — it now returns the pool itself + // for org-scoped subs, which would N-times-count. const pooled = await aggregateOrgMemberStats(organizationId) const totalCurrentUsage = pooled.currentPeriodCost @@ -429,9 +411,6 @@ export async function getSimplifiedBillingSummary( } } - // Individual billing summary. Fetch copilot cost for the breakdown - // inside `usage`; pool across org members when org-scoped so the - // display numbers match what enforcement sees. const userStatsRows = await db .select({ currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index b5484ddc868..3865028cee6 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -143,7 +143,6 @@ export async function getOrganizationBillingData( // Calculate aggregated statistics let totalCurrentUsage = members.reduce((sum, m) => sum + m.currentUsage, 0) - // Deduct daily refresh from pooled usage if (isPaid(subscription.plan) && subscription.periodStart) { const planDollars = getPlanTierDollars(subscription.plan) if (planDollars > 0) { @@ -153,44 +152,33 @@ export async function getOrganizationBillingData( periodStart: subscription.periodStart, periodEnd: subscription.periodEnd ?? null, planDollars, - seats: subscription.seats ?? 1, + seats: subscription.seats || 1, }) totalCurrentUsage = Math.max(0, totalCurrentUsage - refreshConsumed) } } - // Get per-seat pricing for the plan const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan) - // Licensed seats mirrors Stripe's subscription quantity. Default to 1 - // for null rows to match the unified `basePrice × (seats ?? 1)` formula - // used in `getOrgUsageLimit`, `updateOrganizationUsageLimit`, overage, - // and threshold billing — so admin dashboard math agrees with - // enforcement math. - const licensedSeats = subscription.seats ?? 1 + // Stripe subscription quantity; `||` not `??` because 0 seats is + // never valid for a paid sub — fall through to 1. + const licensedSeats = subscription.seats || 1 - // For seat count used in UI (invitations, team management): - // Team: seats column (Stripe quantity) - // Enterprise: metadata.seats (allocated seats, not Stripe quantity which is always 1) + // UI seat count — metadata.seats on enterprise (column is always 1). const effectiveSeats = getEffectiveSeats(subscription) - // Calculate minimum billing amount let minimumBillingAmount: number let totalUsageLimit: number if (isEnterprise(subscription.plan)) { - // Enterprise has fixed pricing set through custom Stripe product - // Their usage limit is configured to match their monthly cost const configuredLimit = organizationData.orgUsageLimit ? Number.parseFloat(organizationData.orgUsageLimit) : 0 - minimumBillingAmount = configuredLimit // For enterprise, this equals their fixed monthly cost - totalUsageLimit = configuredLimit // Same as their monthly cost + minimumBillingAmount = configuredLimit + totalUsageLimit = configuredLimit } else { - // Team plan: Billing is based on licensed seats from Stripe minimumBillingAmount = licensedSeats * pricePerSeat - // Total usage limit: never below the minimum based on licensed seats const configuredLimit = organizationData.orgUsageLimit ? Number.parseFloat(organizationData.orgUsageLimit) : null @@ -202,7 +190,6 @@ export async function getOrganizationBillingData( const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0 - // Billing period comes from the organization's subscription const billingPeriodStart = subscription.periodStart || null const billingPeriodEnd = subscription.periodEnd || null @@ -211,9 +198,9 @@ export async function getOrganizationBillingData( organizationName: organizationData.name || '', subscriptionPlan: subscription.plan, subscriptionStatus: subscription.status || 'inactive', - totalSeats: effectiveSeats, // Uses metadata.seats for enterprise, seats column for team + totalSeats: effectiveSeats, usedSeats: members.length, - seatsCount: licensedSeats, // Used for billing calculations (Stripe quantity) + seatsCount: licensedSeats, totalCurrentUsage: roundCurrency(totalCurrentUsage), totalUsageLimit: roundCurrency(totalUsageLimit), minimumBillingAmount: roundCurrency(minimumBillingAmount), @@ -260,7 +247,6 @@ export async function updateOrganizationUsageLimit( return { success: false, error: 'An active subscription is required to edit usage limits' } } - // Enterprise plans have fixed usage limits that cannot be changed if (isEnterprise(subscription.plan)) { return { success: false, @@ -268,11 +254,6 @@ export async function updateOrganizationUsageLimit( } } - // Any non-enterprise, non-free plan attached to an org can have its - // usage limit edited at the org level. This intentionally covers - // `pro_*` plans that have been transferred to an organization — - // they pool usage and are enforced against `organization.orgUsageLimit` - // just like team plans are. if (!isPaid(subscription.plan)) { return { success: false, @@ -281,10 +262,7 @@ export async function updateOrganizationUsageLimit( } const { basePrice } = getPlanPricing(subscription.plan) - // Minimum = basePrice × seats (or × 1 if no seat count), mirroring - // Stripe's `price × quantity`. Keep this in sync with - // `getOrgUsageLimit` and `setUsageLimitForCredits`. - const seatCount = subscription.seats ?? 1 + const seatCount = subscription.seats || 1 const minimumLimit = seatCount * basePrice if (newLimit < minimumLimit) { @@ -294,8 +272,6 @@ export async function updateOrganizationUsageLimit( } } - // Update the organization usage limit - // Convert number to string for decimal column await db .update(organization) .set({ diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 2a870c12e5f..23114d30c8b 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -62,13 +62,10 @@ export async function writeBillingInterval( } /** - * Sync the subscription's `plan` column to match what Stripe currently - * says it is. This closes a historical gap where Stripe-side plan changes - * (e.g. Pro → Team upgrades, tier swaps) updated `seats` / `referenceId` / - * pricing at Stripe but left the DB's `plan` column stale (see - * customer sub `pro_6000` attached to an org with `seats=2`). - * - * Returns `true` if a write was issued, `false` if no update was needed. + * Sync the subscription's `plan` column to match Stripe. Closes a gap + * where plan changes (Pro → Team upgrades, tier swaps) updated price, + * seats, and referenceId at Stripe but left the DB plan stale. Returns + * `true` if a write was issued, `false` if no change was needed. */ export async function syncSubscriptionPlan( subscriptionId: string, diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index ccd37807a9a..efc5cd0b3ee 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -41,14 +41,9 @@ export interface OrgUsageLimitResult { /** * Calculates the effective usage limit for an organization-scoped plan. - * - Enterprise: Uses orgUsageLimit directly (fixed pricing). - * - Everything else (team, plus `pro_*` transferred to an org): minimum - * floor is `basePrice × seats`, mirroring Stripe's `price × quantity`. - * `seats` defaults to 1 when null (matches Stripe's default quantity). - * - * Returns `{ limit, minimum }` where `limit` is the greater of the - * configured `orgUsageLimit` and the computed minimum, and `minimum` is - * the plan-driven floor. + * Enterprise uses the configured orgUsageLimit directly; every other + * paid plan uses `basePrice × seats` (Stripe's `price × quantity`) as a + * floor. Returns `{ limit, minimum }` where `limit = max(configured, minimum)`. */ export async function getOrgUsageLimit( organizationId: string, @@ -76,13 +71,8 @@ export async function getOrgUsageLimit( } const { basePrice } = getPlanPricing(plan) - // Minimum floor = basePrice × seats. Stripe bills `price × quantity` - // for every paid non-enterprise plan, and `seats` is the mirror of the - // Stripe subscription's quantity. Personal Pro subs have seats=null → - // floor = basePrice × 1 = basePrice; team subs with N seats → basePrice - // × N; and `pro_*` plans that were transferred to an org (data drift) - // keep whatever quantity Stripe is actually charging for. - const seatCount = seats ?? 1 + // `||` not `??` — 0 is never a valid seat count for a paid sub. + const seatCount = seats || 1 const minimum = seatCount * basePrice if (configured !== null) { @@ -162,9 +152,8 @@ export async function getUserUsageData(userId: string): Promise { let currentUsageDecimal = toDecimal(stats.currentPeriodCost) - // For personally-scoped Pro users, include any snapshotted usage (from - // when they previously joined a team) so the display reflects total Pro - // usage. Org-scoped subs use pooled values computed below instead. + // For personally-scoped Pro users, include any snapshotted usage from + // a prior org-join so the display reflects their total Pro usage. if (subscription && isPro(subscription.plan) && !orgScoped) { const snapshotUsageDecimal = toDecimal(stats.proPeriodCostSnapshot) if (snapshotUsageDecimal.greaterThan(0)) { @@ -179,14 +168,12 @@ export async function getUserUsageData(userId: string): Promise { } let currentUsage = toNumber(currentUsageDecimal) - // Determine usage limit based on subscription scope (not plan name). - // Any subscription whose referenceId is an organization is org-scoped, - // including `pro_*` plans that have been transferred to an org. let limit: number + // Shared between the pooled-usage and pooled-refresh blocks so we + // don't issue the member lookup twice per org-scoped call. + let orgMemberIds: string[] = [] if (orgScoped && subscription) { - // Org-scoped: use the organization's pooled limit, and surface the - // organization's pooled current period usage (not this user's slice). const orgLimit = await getOrgUsageLimit( subscription.referenceId, subscription.plan, @@ -198,13 +185,13 @@ export async function getUserUsageData(userId: string): Promise { .select({ userId: member.userId }) .from(member) .where(eq(member.organizationId, subscription.referenceId)) + orgMemberIds = teamMembers.map((m) => m.userId) - if (teamMembers.length > 0) { - const memberIds = teamMembers.map((m) => m.userId) + if (orgMemberIds.length > 0) { const rows = await db .select({ current: userStats.currentPeriodCost }) .from(userStats) - .where(inArray(userStats.userId, memberIds)) + .where(inArray(userStats.userId, orgMemberIds)) let pooled = toDecimal(0) for (const row of rows) { @@ -213,36 +200,26 @@ export async function getUserUsageData(userId: string): Promise { currentUsage = toNumber(pooled) } } else { - // Personally-scoped Free/Pro: use individual user limit from userStats limit = stats.currentUsageLimit ? toNumber(toDecimal(stats.currentUsageLimit)) : getFreeTierLimit() } - // Derive billing period dates from subscription (source of truth). const billingPeriodStart = subscription?.periodStart ?? null const billingPeriodEnd = subscription?.periodEnd ?? null - // Compute daily refresh deduction. Apply at the pooled level for - // org-scoped subscriptions (which includes `pro_*` plans attached to - // an organization) and at the individual level otherwise. let dailyRefreshConsumed = 0 if (subscription && isPaid(subscription.plan) && billingPeriodStart) { const planDollars = getPlanTierDollars(subscription.plan) if (planDollars > 0) { if (orgScoped) { - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, subscription.referenceId)) - const memberIds = teamMembers.map((m) => m.userId) - if (memberIds.length > 0) { + if (orgMemberIds.length > 0) { dailyRefreshConsumed = await computeDailyRefreshConsumed({ - userIds: memberIds, + userIds: orgMemberIds, periodStart: billingPeriodStart, periodEnd: billingPeriodEnd, planDollars, - seats: subscription.seats ?? 1, + seats: subscription.seats || 1, }) } } else { @@ -294,9 +271,6 @@ export async function getUserUsageLimitInfo(userId: string): Promise { .limit(1) if (existingStats.length > 0) { - return // User already has usage stats + return } - // Check user's subscription to determine initial limit. Org-scoped - // subscriptions (including `pro_*` attached to an org) null out the - // individual limit and defer to the organization's usage limit. const subscription = await getHighestPrioritySubscription(userId) const orgScoped = isOrgScopedSubscription(subscription, userId) @@ -379,9 +350,6 @@ export async function updateUserUsageLimit( try { const subscription = await getHighestPrioritySubscription(userId) - // Org-scoped subscriptions (Team/Enterprise, and `pro_*` plans that have - // been attached to an organization) do not have individual limits — they - // are edited through the organization endpoint. if (isOrgScopedSubscription(subscription, userId)) { return { success: false, @@ -441,12 +409,9 @@ export async function updateUserUsageLimit( } /** - * Get usage limit for a user (used by checkUsageStatus for server-side checks). - * - * Branches on subscription scope, not plan name: - * - Org-scoped (any subscription whose `referenceId` is an organization, - * including `pro_*` plans attached to an org): organization usage limit. - * - Personally-scoped Free/Pro: individual user limit from userStats. + * Get usage limit for a user (used by checkUsageStatus for server-side + * checks). Org-scoped subs return the organization limit; + * personally-scoped subs return the individual user limit from userStats. */ export async function getUserUsageLimit( userId: string, @@ -476,7 +441,6 @@ export async function getUserUsageLimit( return orgLimit.limit } - // Personally-scoped Free/Pro: Use individual limit from userStats const userStatsQuery = await db .select({ currentUsageLimit: userStats.currentUsageLimit }) .from(userStats) @@ -540,8 +504,6 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise { const subscription = await getHighestPrioritySubscription(userId) @@ -698,7 +654,7 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise { - const subscription = await getHighestPrioritySubscription(userId) - - // Credits live on the entity that owns the subscription. For any - // org-scoped sub (including `pro_*` plans attached to an org), credits - // are read from `organization.creditBalance`. - if (isOrgScopedSubscription(subscription, userId) && subscription) { - const orgRows = await db +/** + * Read credit balance directly from a known entity (user or organization). + * Use this in webhook / admin paths that already know the target entity — + * unlike `getCreditBalance(userId)` it does not route through + * `getHighestPrioritySubscription`, so callers don't need to resolve the + * org owner as a user-id proxy. + */ +export async function getCreditBalanceForEntity( + entityType: 'user' | 'organization', + entityId: string +): Promise { + if (entityType === 'organization') { + const rows = await db .select({ creditBalance: organization.creditBalance }) .from(organization) - .where(eq(organization.id, subscription.referenceId)) + .where(eq(organization.id, entityId)) .limit(1) + return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0 + } + + const rows = await db + .select({ creditBalance: userStats.creditBalance }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0 +} + +export async function getCreditBalance(userId: string): Promise { + const subscription = await getHighestPrioritySubscription(userId) + if (isOrgScopedSubscription(subscription, userId) && subscription) { return { - balance: orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].creditBalance)) : 0, + balance: await getCreditBalanceForEntity('organization', subscription.referenceId), entityType: 'organization', entityId: subscription.referenceId, } } - const userRows = await db - .select({ creditBalance: userStats.creditBalance }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - return { - balance: userRows.length > 0 ? toNumber(toDecimal(userRows[0].creditBalance)) : 0, + balance: await getCreditBalanceForEntity('user', userId), entityType: 'user', entityId: userId, } diff --git a/apps/sim/lib/billing/credits/purchase.ts b/apps/sim/lib/billing/credits/purchase.ts index d2137a67d49..75ed3e402ee 100644 --- a/apps/sim/lib/billing/credits/purchase.ts +++ b/apps/sim/lib/billing/credits/purchase.ts @@ -25,11 +25,8 @@ export async function setUsageLimitForCredits( ): Promise { try { const { basePrice } = getPlanPricing(plan) - // planBase = basePrice × seats (or × 1 if no seat count). Matches - // Stripe's `price × quantity` billing for every paid non-enterprise - // plan. Keep consistent with `getOrgUsageLimit` and - // `updateOrganizationUsageLimit`. - const seatCount = seats ?? 1 + // `||` not `??` — 0 is never a valid seat count for a paid sub. + const seatCount = seats || 1 const planBase = Number(basePrice) * seatCount const creditBalanceNum = Number(creditBalance) const newLimit = planBase + creditBalanceNum @@ -133,8 +130,7 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise m.userId) diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 8b38f0a359a..cbc3b6ee28e 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -414,9 +414,6 @@ export async function addUserToOrganization(params: AddMemberParams): Promise isPaid(s.plan)) } diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts index 9d817545e97..54e7abbe4b4 100644 --- a/apps/sim/lib/billing/plan-helpers.ts +++ b/apps/sim/lib/billing/plan-helpers.ts @@ -43,22 +43,11 @@ export function isPaid(plan: string | null | undefined): boolean { } /** - * True when the plan **name** is a team/enterprise plan. - * - * WARNING: This is a plan-name check, NOT a scope check. It answers "is - * this a team- or enterprise-branded plan?" — which is NOT the same as - * "is this subscription attached to an organization?". - * - * A `pro_*` plan whose `referenceId` is an org id is org-scoped at the - * billing level even though `isOrgPlan` returns `false` for its plan name. - * - * For **scope decisions** (pooling usage, routing edits, choosing user vs - * organization context), use: - * - `isOrgScopedSubscription(sub, userId)` (pure, in `subscriptions/utils.ts`) - * - `isSubscriptionOrgScoped(sub)` (async DB lookup, in `core/billing.ts`) - * - * Keep using `isOrgPlan` only for plan-name semantics — e.g. display tier, - * "did the user purchase a team-branded plan?" during checkout, etc. + * True when the plan **name** is a team/enterprise plan. This is a + * plan-name check, NOT a scope check — a `pro_*` plan attached to an + * organization is org-scoped at the billing level even though this + * returns `false` for it. For scope decisions use + * `isOrgScopedSubscription` (sync) or `isSubscriptionOrgScoped` (async). */ export function isOrgPlan(plan: string | null | undefined): boolean { return isTeam(plan) || isEnterprise(plan) diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts index 36e05d33605..7cfb6b19ff6 100644 --- a/apps/sim/lib/billing/storage/limits.ts +++ b/apps/sim/lib/billing/storage/limits.ts @@ -88,8 +88,7 @@ export async function getUserStorageLimit(userId: string): Promise { return limits.free } - // Any org-scoped subscription (team, enterprise, or `pro_*` attached to - // an org) uses pooled org-level storage. Custom limits come from the + // Org-scoped subs use pooled org-level storage. Custom limits come from the // subscription metadata; otherwise use the team/enterprise default. if (isOrgScopedSubscription(sub, userId)) { const orgRecord = await db @@ -131,8 +130,8 @@ export async function getUserStorageUsage(userId: string): Promise { const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - // Org-scoped subscriptions (including `pro_*` on org) share pooled - // `organization.storageUsedBytes`; personal plans use `userStats`. + // Org-scoped subs share pooled `organization.storageUsedBytes`; + // personal plans use `userStats`. if (isOrgScopedSubscription(sub, userId) && sub) { const orgRecord = await db .select({ storageUsedBytes: organization.storageUsedBytes }) diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index dddb00488d7..8fb3d962efb 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -27,8 +27,7 @@ export async function incrementStorageUsage(userId: string, bytes: number): Prom const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription') const sub = await getHighestPrioritySubscription(userId) - // Org-scoped subscriptions (including `pro_*` on org) pool storage at - // the org level; personal plans track per-user. + // Org-scoped subs pool at the org level; personal plans per-user. if (isOrgScopedSubscription(sub, userId) && sub) { await db .update(organization) diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index 29f48acb707..746a31bf2ce 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -93,9 +93,8 @@ export function getEffectiveSeats(subscription: any): number { return 0 } - // Team plans and `pro_*` plans attached to an organization both expose - // licensed seats via the `seats` column on the Stripe subscription. - // Personally-scoped `pro_*` subs have no seat concept, so they return 0. + // Mirrors the Stripe subscription's `quantity`. For personal Pro this + // is null in practice, so `?? 0` returns 0. if (isTeam(subscription.plan) || isPro(subscription.plan)) { return subscription.seats ?? 0 } @@ -112,17 +111,10 @@ export function checkTeamPlan(subscription: any): boolean { } /** - * Returns true if the subscription's `referenceId` points at an organization - * (i.e. it is not the caller's own `userId`). - * - * Prefer this over plan-name checks (`isOrgPlan`, `isTeam`) when deciding - * whether reads/writes of the usage limit should be routed through the - * organization or the user. A subscription with plan `pro_6000` whose - * `referenceId` is an org id is org-scoped and must be treated as such, - * even though `isTeam`/`isOrgPlan` return false for its plan name. - * - * Callers should pass the user id whose perspective is being evaluated - * (normally the authenticated user or the billed-account user). + * True when the subscription's `referenceId` is an org (i.e. not the + * caller's own `userId`). Prefer this over plan-name checks for scope + * decisions — a `pro_*` sub attached to an org is org-scoped even though + * `isTeam` / `isOrgPlan` return false. */ export function isOrgScopedSubscription( subscription: { referenceId?: string | null } | null | undefined, diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 9234f0e570f..9fac8aef0b9 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -123,9 +123,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise = [] const orgScoped = await isSubscriptionOrgScoped(sub) @@ -438,17 +436,10 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0') const currentCopilot = currentStats[0].currentCopilot || '0' - // Attribution rule (mirrors `calculateSubscriptionOverage` personal - // Pro branch): if the user joined a paid org mid-cycle, - // `proPeriodCostSnapshot` holds the pre-join usage that was billed - // on this invoice, while `currentPeriodCost` is post-join usage - // that's still being billed by the org. Preserve `currentPeriodCost` - // (and `currentPeriodCopilotCost`) so the org's next cycle-close - // captures them; only retire the snapshot and personal-billing - // trackers here. `lastPeriodCopilotCost` is cleared because the - // invitation-accept flow resets `currentPeriodCopilotCost` (without - // snapshotting it), so the personal Pro's final period had no - // trackable copilot usage to report as "last period". + // Snapshot > 0: user joined a paid org mid-cycle. The pre-join + // portion was billed on this invoice (snapshot); `currentPeriodCost` + // is post-join usage the org will bill next cycle-close, so keep + // it. Only retire the personal-billing trackers here. if (snapshot > 0) { await db .update(userStats) @@ -511,19 +502,7 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise 0) { const sub = subscription[0] - // `getCreditBalance` is keyed by user id. For org-scoped entities we - // read from the owner's perspective so it traverses through to - // `organization.creditBalance`. - let balanceUserId = entityId - if (entityType === 'organization') { - const ownerRow = await db - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, entityId), eq(member.role, 'owner'))) - .limit(1) - balanceUserId = ownerRow[0]?.userId ?? (purchasedBy || entityId) - } - const { balance: newCreditBalance } = await getCreditBalance(balanceUserId) + const newCreditBalance = await getCreditBalanceForEntity(entityType, entityId) await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance) } @@ -535,12 +514,8 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise = [] if (entityType === 'organization') { @@ -834,18 +809,7 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { if (remainingOverage > 0) { const entityType = (await isSubscriptionOrgScoped(sub)) ? 'organization' : 'user' const entityId = sub.referenceId - // `getCreditBalance` is keyed by user id; when we need the org balance - // we call it on the org owner to read the pooled org credits. - let balanceUserId = entityId - if (entityType === 'organization') { - const ownerRow = await db - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, entityId), eq(member.role, 'owner'))) - .limit(1) - balanceUserId = ownerRow[0]?.userId ?? entityId - } - const { balance: creditBalance } = await getCreditBalance(balanceUserId) + const creditBalance = await getCreditBalanceForEntity(entityType, entityId) if (creditBalance > 0) { creditsApplied = Math.min(creditBalance, remainingOverage) diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index f4097b09f18..369d24cd1ff 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -330,10 +330,6 @@ export async function handleSubscriptionDeleted(subscription: { let organizationDeleted = false let membersSynced = 0 - // Route cleanup by subscription scope (where the referenceId points), - // not plan name. A `pro_*` attached to an org needs org-level cleanup, - // not the per-user sync path (which would treat `referenceId` as a user - // id and silently do nothing for members). if (await isSubscriptionOrgScoped(subscription)) { const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) restoredProCount = cleanup.restoredProCount diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.ts index b737bc7ffc8..85366e80400 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.ts @@ -42,10 +42,6 @@ export class RateLimiter { private getRateLimitKey(userId: string, subscription: SubscriptionInfo | null): string { if (!subscription) return userId - // Pool rate limits at the org level whenever the subscription is - // attached to an organization — this covers team/enterprise AND - // `pro_*` plans that have been transferred to an org (plan name alone - // is not a reliable signal of scope). if (isOrgScopedSubscription(subscription, userId)) { return subscription.referenceId } diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index df8398ac01b..ef96063387c 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -416,9 +416,6 @@ export class ExecutionLogger implements IExecutionLoggerService { const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers') const { isOrgScopedSubscription } = await import('@/lib/billing/subscriptions/utils') const planName = getDisplayPlanName(sub?.plan) - // Scope the threshold email by the subscription's referenceId, not - // its plan name. This correctly handles `pro_*` plans attached to - // an org: the 80% warning should go to org admins, not the user. const scope: 'user' | 'organization' = isOrgScopedSubscription(sub, usr.id) ? 'organization' : 'user' From 62727f0c8f757318f5697a50ee47e98012134df5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 17 Apr 2026 23:24:37 -0700 Subject: [PATCH 05/11] harden further --- apps/sim/lib/billing/client/types.ts | 5 +++++ apps/sim/lib/billing/client/utils.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/billing/client/types.ts b/apps/sim/lib/billing/client/types.ts index 6122b190026..c59c287a072 100644 --- a/apps/sim/lib/billing/client/types.ts +++ b/apps/sim/lib/billing/client/types.ts @@ -25,6 +25,9 @@ export interface SubscriptionData { isPro: boolean isTeam: boolean isEnterprise: boolean + /** True when the subscription's `referenceId` is an organization. */ + isOrgScoped: boolean + organizationId: string | null plan: string status: string | null seats: number | null @@ -59,6 +62,8 @@ export interface SubscriptionStore { isPro: boolean isTeam: boolean isEnterprise: boolean + isOrgScoped: boolean + organizationId: string | null isFree: boolean plan: string status: string | null diff --git a/apps/sim/lib/billing/client/utils.ts b/apps/sim/lib/billing/client/utils.ts index 206af4d5bdf..91211902e71 100644 --- a/apps/sim/lib/billing/client/utils.ts +++ b/apps/sim/lib/billing/client/utils.ts @@ -31,6 +31,8 @@ export function getSubscriptionStatus( isPro: subscriptionData?.isPro ?? false, isTeam: subscriptionData?.isTeam ?? false, isEnterprise: subscriptionData?.isEnterprise ?? false, + isOrgScoped: subscriptionData?.isOrgScoped ?? false, + organizationId: subscriptionData?.organizationId ?? null, isFree: !(subscriptionData?.isPaid ?? false), plan: subscriptionData?.plan ?? 'free', status: subscriptionData?.status ?? null, @@ -45,7 +47,12 @@ export function getSubscriptionAccessState( const status = getSubscriptionStatus(subscriptionData) const billingBlocked = Boolean(subscriptionData?.billingBlocked) const hasUsablePaidAccess = hasUsableSubscriptionAccess(status.status, billingBlocked) - const hasUsableTeamAccess = hasUsablePaidAccess && (status.isTeam || status.isEnterprise) + // Team-management features (invitations, seats, roles) are available on + // any paid subscription attached to an organization — including `pro_*` + // plans that have been transferred to an org. Plan-name gating would + // miss those. + const hasUsableTeamAccess = + hasUsablePaidAccess && (status.isOrgScoped || status.isTeam || status.isEnterprise) const hasUsableEnterpriseAccess = hasUsablePaidAccess && status.isEnterprise const hasUsableMaxAccess = hasUsablePaidAccess && (getPlanTierCredits(status.plan) >= 25000 || isEnterprise(status.plan)) From e561c3b5047559a3f46e3837d5156ab0529b7a70 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 18 Apr 2026 02:05:14 -0700 Subject: [PATCH 06/11] outbox service --- apps/sim/app/api/billing/route.ts | 15 +- .../[id]/invitations/[invitationId]/route.ts | 80 +- .../api/organizations/[id]/members/route.ts | 47 +- .../admin/organizations/[id]/members/route.ts | 23 - .../api/v1/admin/outbox/[id]/requeue/route.ts | 71 + apps/sim/app/api/v1/admin/outbox/route.ts | 91 + .../api/v1/admin/subscriptions/[id]/route.ts | 49 +- .../app/api/webhooks/outbox/process/route.ts | 49 + apps/sim/hooks/queries/organization.ts | 2 + apps/sim/lib/auth/auth.ts | 9 +- .../lib/billing/calculations/usage-monitor.ts | 23 +- apps/sim/lib/billing/core/billing.ts | 23 +- apps/sim/lib/billing/core/organization.ts | 10 +- apps/sim/lib/billing/core/usage.ts | 16 +- apps/sim/lib/billing/credits/daily-refresh.ts | 82 +- .../lib/billing/organizations/membership.ts | 94 +- apps/sim/lib/billing/threshold-billing.ts | 233 +- apps/sim/lib/billing/webhooks/idempotency.ts | 39 + apps/sim/lib/billing/webhooks/invoices.ts | 515 +- .../lib/billing/webhooks/outbox-handlers.ts | 203 + apps/sim/lib/billing/webhooks/subscription.ts | 344 +- apps/sim/lib/core/idempotency/service.ts | 57 +- apps/sim/lib/core/outbox/service.test.ts | 385 + apps/sim/lib/core/outbox/service.ts | 366 + packages/db/migrations/0191_unusual_mongu.sql | 17 + .../db/migrations/meta/0191_snapshot.json | 14785 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 25 + 28 files changed, 16957 insertions(+), 703 deletions(-) create mode 100644 apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts create mode 100644 apps/sim/app/api/v1/admin/outbox/route.ts create mode 100644 apps/sim/app/api/webhooks/outbox/process/route.ts create mode 100644 apps/sim/lib/billing/webhooks/idempotency.ts create mode 100644 apps/sim/lib/billing/webhooks/outbox-handlers.ts create mode 100644 apps/sim/lib/core/outbox/service.test.ts create mode 100644 apps/sim/lib/core/outbox/service.ts create mode 100644 packages/db/migrations/0191_unusual_mongu.sql create mode 100644 packages/db/migrations/meta/0191_snapshot.json diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index 559e4f76df4..2df0d6f2f65 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -45,7 +45,20 @@ export async function GET(request: NextRequest) { let billingData if (context === 'user') { - // Get user billing and billing blocked status in parallel + if (contextId) { + const membership = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, contextId), eq(member.userId, session.user.id))) + .limit(1) + if (membership.length === 0) { + return NextResponse.json( + { error: 'Access denied - not a member of this organization' }, + { status: 403 } + ) + } + } + const [billingResult, billingStatus] = await Promise.all([ getSimplifiedBillingSummary(session.user.id, contextId || undefined), getEffectiveBillingStatus(session.user.id), diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index fc07c9b6221..269efb6a32c 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -23,8 +23,9 @@ import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' -import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' @@ -328,8 +329,6 @@ export async function PUT( } } - let personalProToCancel: any = null - await db.transaction(async (tx) => { await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId)) @@ -342,8 +341,7 @@ export async function PUT( createdAt: new Date(), }) - // Snapshot Pro usage and cancel Pro subscription when joining a paid team - try { + { const orgSubs = await tx .select() .from(subscriptionTable) @@ -393,8 +391,9 @@ export async function PUT( .update(userStats) .set({ proPeriodCostSnapshot: currentProUsage, - currentPeriodCost: '0', // Reset so new usage is attributed to team - currentPeriodCopilotCost: '0', // Reset copilot cost for new period + proPeriodCostSnapshotAt: new Date(), + currentPeriodCost: '0', + currentPeriodCopilotCost: '0', }) .where(eq(userStats.userId, userId)) @@ -405,20 +404,20 @@ export async function PUT( }) } - // Mark for cancellation after transaction - if (personalPro.cancelAtPeriodEnd !== true) { - personalProToCancel = personalPro + if (personalPro.cancelAtPeriodEnd !== true && personalPro.stripeSubscriptionId) { + await tx + .update(subscriptionTable) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscriptionTable.id, personalPro.id)) + + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { + stripeSubscriptionId: personalPro.stripeSubscriptionId, + subscriptionId: personalPro.id, + reason: 'member-joined-paid-org', + }) } } - // Transfer the joining user's pre-join storage bytes into - // the org pool — after this point storage reads/writes route - // through `organization.storageUsedBytes`, so bytes left on - // `user_stats` would be orphaned (and a later decrement from - // deleting a pre-join file would wrongly reduce the org - // pool). `.for('update')` row-locks `user_stats` so a - // concurrent increment/decrement can't land between the - // SELECT and the zero UPDATE and get silently dropped. const storageRows = await tx .select({ storageUsedBytes: userStats.storageUsedBytes }) .from(userStats) @@ -447,13 +446,6 @@ export async function PUT( }) } } - } catch (error) { - logger.error('Failed to handle Pro user joining team', { - userId: session.user.id, - organizationId, - error, - }) - // Don't fail the whole invitation acceptance due to this } // Auto-assign to permission group if one has autoAddNewMembers enabled @@ -593,44 +585,6 @@ export async function PUT( } } - // Handle Pro subscription cancellation after transaction commits - if (personalProToCancel) { - try { - const stripe = requireStripeClient() - if (personalProToCancel.stripeSubscriptionId) { - try { - await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, { - cancel_at_period_end: true, - }) - } catch (stripeError) { - logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', { - userId: session.user.id, - subscriptionId: personalProToCancel.id, - stripeSubscriptionId: personalProToCancel.stripeSubscriptionId, - error: stripeError, - }) - } - } - - await db - .update(subscriptionTable) - .set({ cancelAtPeriodEnd: true }) - .where(eq(subscriptionTable.id, personalProToCancel.id)) - - logger.info('Auto-cancelled personal Pro at period end after joining paid team', { - userId: session.user.id, - personalSubscriptionId: personalProToCancel.id, - organizationId, - }) - } catch (dbError) { - logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', { - userId: session.user.id, - subscriptionId: personalProToCancel.id, - error: dbError, - }) - } - } - if (status === 'accepted') { try { await syncUsageLimitsFromSubscription(session.user.id) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 989d792b6fd..ad4e4001c82 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -1,12 +1,19 @@ import { db } from '@sim/db' -import { invitation, member, organization, user, userStats } from '@sim/db/schema' +import { + invitation, + member, + organization, + subscription as subscriptionTable, + user, + userStats, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' -import { getUserUsageData } from '@/lib/billing/core/usage' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' @@ -83,16 +90,32 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .leftJoin(userStats, eq(user.id, userStats.userId)) .where(eq(member.organizationId, organizationId)) - const membersWithUsage = await Promise.all( - base.map(async (row) => { - const usage = await getUserUsageData(row.userId) - return { - ...row, - billingPeriodStart: usage.billingPeriodStart, - billingPeriodEnd: usage.billingPeriodEnd, - } + // The billing period is the same for every member — it comes from + // whichever subscription covers them. Fetch once and attach to + // every row instead of calling `getUserUsageData` per-member, + // which would run an O(N) pooled query for each of N rows. + const [orgSub] = await db + .select({ + periodStart: subscriptionTable.periodStart, + periodEnd: subscriptionTable.periodEnd, }) - ) + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, organizationId), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) + + const billingPeriodStart = orgSub?.periodStart ?? null + const billingPeriodEnd = orgSub?.periodEnd ?? null + + const membersWithUsage = base.map((row) => ({ + ...row, + billingPeriodStart, + billingPeriodEnd, + })) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index cc9cee63206..1da561752ad 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -33,7 +33,6 @@ import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { addUserToOrganization } from '@/lib/billing/organizations/membership' -import { requireStripeClient } from '@/lib/billing/stripe-client' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -229,28 +228,6 @@ export const POST = withAdminAuthParams(async (request, context) => return badRequestResponse(result.error || 'Failed to add member') } - if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) { - try { - const stripe = requireStripeClient() - await stripe.subscriptions.update( - result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - { cancel_at_period_end: true } - ) - logger.info('Admin API: Synced Pro cancellation with Stripe', { - userId: body.userId, - subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, - stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - }) - } catch (stripeError) { - logger.error('Admin API: Failed to sync Pro cancellation with Stripe', { - userId: body.userId, - subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, - stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - error: stripeError, - }) - } - } - const data: AdminMember = { id: result.memberId!, userId: body.userId, diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts new file mode 100644 index 00000000000..9de5c4696be --- /dev/null +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -0,0 +1,71 @@ +import { db } from '@sim/db' +import { outboxEvent } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' + +const logger = createLogger('AdminOutboxRequeueAPI') + +export const dynamic = 'force-dynamic' + +/** + * POST /api/v1/admin/outbox/[id]/requeue + * + * Move a dead-lettered outbox event back to `pending` so the worker + * will retry it. Resets `attempts`, `lastError`, and `availableAt` so + * the next poll picks it up. Only dead-lettered events can be + * requeued — completed/pending/processing rows are rejected to avoid + * operator errors. + */ +export const POST = withAdminAuthParams<{ id: string }>(async (_request, { params }) => { + const { id } = await params + + try { + const result = await db + .update(outboxEvent) + .set({ + status: 'pending', + attempts: 0, + lastError: null, + availableAt: new Date(), + lockedAt: null, + processedAt: null, + }) + .where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter'))) + .returning({ id: outboxEvent.id, eventType: outboxEvent.eventType }) + + if (result.length === 0) { + return NextResponse.json( + { + success: false, + error: + 'Event not found or not in dead_letter status. Only dead-lettered events can be requeued.', + }, + { status: 404 } + ) + } + + logger.info('Requeued dead-lettered outbox event', { + eventId: result[0].id, + eventType: result[0].eventType, + }) + + return NextResponse.json({ + success: true, + requeued: result[0], + }) + } catch (error) { + logger.error('Failed to requeue outbox event', { + eventId: id, + error: error instanceof Error ? error.message : error, + }) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts new file mode 100644 index 00000000000..addcb5bbe67 --- /dev/null +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -0,0 +1,91 @@ +import { db } from '@sim/db' +import { outboxEvent } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, desc, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { withAdminAuth } from '@/app/api/v1/admin/middleware' + +const logger = createLogger('AdminOutboxAPI') + +export const dynamic = 'force-dynamic' + +/** + * GET /api/v1/admin/outbox?status=dead_letter&eventType=...&limit=100 + * + * Inspect outbox events for operator triage. Primary use: list + * dead-lettered rows to reconcile Stripe state manually after a + * permanent handler failure (e.g. Stripe account frozen, subscription + * already canceled by another path, etc.). + * + * Filters: + * - `status`: 'pending' | 'processing' | 'completed' | 'dead_letter' (default 'dead_letter') + * - `eventType`: exact match on event_type + * - `limit`: cap rows returned (default 100, max 500) + * + * Response includes aggregate counts by status for quick health read. + */ +export const GET = withAdminAuth(async (request: NextRequest) => { + try { + const { searchParams } = new URL(request.url) + const validStatuses = ['pending', 'processing', 'completed', 'dead_letter'] as const + const status = (searchParams.get('status') ?? 'dead_letter') as (typeof validStatuses)[number] + if (!validStatuses.includes(status)) { + return NextResponse.json( + { + success: false, + error: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, + }, + { status: 400 } + ) + } + + const eventType = searchParams.get('eventType') + + const rawLimit = searchParams.get('limit') + const parsedLimit = rawLimit === null ? 100 : Number.parseInt(rawLimit, 10) + const limit = + Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.min(500, Math.max(1, parsedLimit)) + : 100 + + const whereConditions = [eq(outboxEvent.status, status)] + if (eventType) { + whereConditions.push(eq(outboxEvent.eventType, eventType)) + } + + const rows = await db + .select() + .from(outboxEvent) + .where(and(...whereConditions)) + .orderBy(desc(outboxEvent.createdAt)) + .limit(limit) + + // Aggregate counts per (status, eventType) for at-a-glance health. + const counts = await db + .select({ + status: outboxEvent.status, + eventType: outboxEvent.eventType, + count: sql`count(*)::int`, + }) + .from(outboxEvent) + .groupBy(outboxEvent.status, outboxEvent.eventType) + + return NextResponse.json({ + success: true, + filter: { status, eventType, limit }, + rows, + counts, + }) + } catch (error) { + logger.error('Failed to list outbox events', { + error: error instanceof Error ? error.message : error, + }) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts index 50ba40f3338..fa465d7286d 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts @@ -28,6 +28,8 @@ import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -91,28 +93,31 @@ export const DELETE = withAdminAuthParams(async (request, context) return badRequestResponse('Subscription has no Stripe subscription ID') } - const stripe = requireStripeClient() - if (atPeriodEnd) { - // Schedule cancellation at period end - await stripe.subscriptions.update(existing.stripeSubscriptionId, { - cancel_at_period_end: true, + await db.transaction(async (tx) => { + await tx + .update(subscription) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscription.id, subscriptionId)) + + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { + stripeSubscriptionId: existing.stripeSubscriptionId, + subscriptionId: existing.id, + reason: reason ?? 'admin-cancel-at-period-end', + }) }) - // Update DB (webhooks don't sync cancelAtPeriodEnd) - await db - .update(subscription) - .set({ cancelAtPeriodEnd: true }) - .where(eq(subscription.id, subscriptionId)) - - logger.info('Admin API: Scheduled subscription cancellation at period end', { - subscriptionId, - stripeSubscriptionId: existing.stripeSubscriptionId, - plan: existing.plan, - referenceId: existing.referenceId, - periodEnd: existing.periodEnd, - reason, - }) + logger.info( + 'Admin API: Scheduled subscription cancellation at period end (DB committed, Stripe queued)', + { + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + plan: existing.plan, + referenceId: existing.referenceId, + periodEnd: existing.periodEnd, + reason, + } + ) return singleResponse({ success: true, @@ -124,7 +129,11 @@ export const DELETE = withAdminAuthParams(async (request, context) }) } - // Immediate cancellation + // Immediate cancellation — stays synchronous. Stripe's + // `customer.subscription.deleted` webhook triggers full cleanup + // (overage bill, usage reset, Pro restore, org delete) via + // `handleSubscriptionDeleted`, so no outbox needed here. + const stripe = requireStripeClient() await stripe.subscriptions.cancel(existing.stripeSubscriptionId, { prorate: true, invoice_now: true, diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts new file mode 100644 index 00000000000..99b3f02baf6 --- /dev/null +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers' +import { processOutboxEvents } from '@/lib/core/outbox/service' +import { generateRequestId } from '@/lib/core/utils/request' + +const logger = createLogger('OutboxProcessorAPI') + +export const dynamic = 'force-dynamic' +export const maxDuration = 120 + +const handlers = { + ...billingOutboxHandlers, +} as const + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authError = verifyCronAuth(request, 'Outbox processor') + if (authError) { + return authError + } + + const result = await processOutboxEvents(handlers, { batchSize: 20 }) + + logger.info('Outbox processing completed', { requestId, ...result }) + + return NextResponse.json({ + success: true, + requestId, + result, + }) + } catch (error) { + logger.error('Outbox processing failed', { + requestId, + error: error instanceof Error ? error.message : error, + }) + return NextResponse.json( + { + success: false, + requestId, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index fdb6b60a5bb..3d3a65b8df6 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -3,6 +3,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import { client } from '@/lib/auth/auth-client' import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers' import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' +import { subscriptionKeys } from '@/hooks/queries/subscription' const logger = createLogger('OrganizationQueries') @@ -330,6 +331,7 @@ export function useRemoveMember() { queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) + queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) }, }) } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 4e373223caf..32237b4119a 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -3064,6 +3064,7 @@ export const auth = betterAuth({ await writeBillingInterval(resolvedSubscription.id, isAnnual ? 'year' : 'month') }, onSubscriptionDeleted: async ({ + event, subscription, }: { event: Stripe.Event @@ -3071,18 +3072,24 @@ export const auth = betterAuth({ subscription: any }) => { logger.info('[onSubscriptionDeleted] Subscription deleted', { + eventId: event.id, subscriptionId: subscription.id, referenceId: subscription.referenceId, }) try { - await handleSubscriptionDeleted(subscription) + await handleSubscriptionDeleted(subscription, event.id) } catch (error) { logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', { + eventId: event.id, subscriptionId: subscription.id, referenceId: subscription.referenceId, error, }) + // Rethrow so the Stripe webhook retries — otherwise + // the final overage invoice, usage reset, org cleanup, + // and personal Pro restore can be permanently skipped. + throw error } }, }, diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 41f82450789..a7a082cba36 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -6,8 +6,11 @@ import { getHighestPrioritySubscription, type HighestPrioritySubscription, } from '@/lib/billing/core/plan' -import { getUserUsageLimit } from '@/lib/billing/core/usage' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' +import { getOrgUsageLimit, getUserUsageLimit } from '@/lib/billing/core/usage' +import { + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, @@ -99,12 +102,14 @@ export async function checkUsageStatus( if (isPaid(sub.plan) && sub.periodStart) { const planDollars = getPlanTierDollars(sub.plan) if (planDollars > 0) { + const userBounds = await getOrgMemberRefreshBounds(sub.referenceId, sub.periodStart) const refresh = await computeDailyRefreshConsumed({ userIds: memberIds, periodStart: sub.periodStart, periodEnd: sub.periodEnd ?? null, planDollars, seats: sub.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) pooled = Math.max(0, pooled - refresh) } @@ -184,12 +189,18 @@ export async function checkUsageStatus( if (!orgSub) continue const [org] = await db - .select({ id: organization.id, orgUsageLimit: organization.orgUsageLimit }) + .select({ id: organization.id }) .from(organization) .where(eq(organization.id, m.organizationId)) .limit(1) if (!org) continue + // Use the same resolver as primary-path enforcement so the + // `basePrice × seats` floor is applied here too. Reading + // `organization.orgUsageLimit` raw would miss the floor when + // the column is null or has drifted below minimum. + const { limit: orgCap } = await getOrgUsageLimit(org.id, orgSub.plan, orgSub.seats) + const teamMembers = await db .select({ userId: member.userId }) .from(member) @@ -212,21 +223,19 @@ export async function checkUsageStatus( const planDollars = getPlanTierDollars(orgSub.plan) if (planDollars > 0) { const memberIds = teamMembers.map((tm) => tm.userId) + const userBounds = await getOrgMemberRefreshBounds(org.id, orgSub.periodStart) const orgRefreshDeduction = await computeDailyRefreshConsumed({ userIds: memberIds, periodStart: orgSub.periodStart, periodEnd: orgSub.periodEnd ?? null, planDollars, seats: orgSub.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) pooledUsage = Math.max(0, pooledUsage - orgRefreshDeduction) } } - const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0 - if (!orgCap || Number.isNaN(orgCap)) { - logger.warn('Organization missing usage limit', { orgId: org.id }) - } if (orgCap > 0 && pooledUsage >= orgCap) { currentUsage = pooledUsage effectiveLimit = orgCap diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index f77093b9dea..cfd74e84154 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -8,7 +8,10 @@ import { } from '@/lib/billing/core/subscription' import { getUserUsageData } from '@/lib/billing/core/usage' import { getCreditBalance } from '@/lib/billing/credits/balance' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' +import { + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, @@ -163,12 +166,14 @@ export async function calculateSubscriptionOverage(sub: { let dailyRefreshDeduction = 0 const planDollars = getPlanTierDollars(sub.plan) if (planDollars > 0 && sub.periodStart) { + const userBounds = await getOrgMemberRefreshBounds(sub.referenceId, sub.periodStart) dailyRefreshDeduction = await computeDailyRefreshConsumed({ userIds: pooled.memberIds, periodStart: sub.periodStart, periodEnd: sub.periodEnd ?? null, planDollars, seats: sub.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) } @@ -199,6 +204,7 @@ export async function calculateSubscriptionOverage(sub: { .select({ currentPeriodCost: userStats.currentPeriodCost, proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, + proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt, }) .from(userStats) .where(eq(userStats.userId, sub.referenceId)) @@ -206,12 +212,9 @@ export async function calculateSubscriptionOverage(sub: { const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0 const snapshotUsage = statsRow ? toNumber(toDecimal(statsRow.proPeriodCostSnapshot)) : 0 + const snapshotAt = statsRow?.proPeriodCostSnapshotAt ?? null - // If snapshot > 0 the user joined a paid org mid-cycle: pre-join - // usage sits in `proPeriodCostSnapshot`, post-join is still - // accumulating in `currentPeriodCost` and will be billed by the org. - // Bill only the snapshot here to avoid double-charging. - const joinedOrgMidCycle = snapshotUsage > 0 + const joinedOrgMidCycle = snapshotAt !== null || snapshotUsage > 0 const totalProUsageDecimal = joinedOrgMidCycle ? toDecimal(snapshotUsage) : toDecimal(personalCurrentUsage) @@ -221,6 +224,7 @@ export async function calculateSubscriptionOverage(sub: { userId: sub.referenceId, preJoinUsage: snapshotUsage, postJoinUsageOnMemberRow: personalCurrentUsage, + snapshotAt: snapshotAt?.toISOString() ?? null, subscriptionId: sub.id, }) } @@ -228,10 +232,15 @@ export async function calculateSubscriptionOverage(sub: { let dailyRefreshDeduction = 0 const planDollars = getPlanTierDollars(sub.plan) if (planDollars > 0 && sub.periodStart) { + // If the user joined an org mid-cycle, their usageLog rows after + // `snapshotAt` belong to the org's pooled refresh. Cap refresh + // to [periodStart, snapshotAt) so post-join refresh isn't + // deducted from pre-join personal Pro usage. + const refreshCap = joinedOrgMidCycle && snapshotAt ? snapshotAt : (sub.periodEnd ?? null) dailyRefreshDeduction = await computeDailyRefreshConsumed({ userIds: [sub.referenceId], periodStart: sub.periodStart, - periodEnd: sub.periodEnd ?? null, + periodEnd: refreshCap, planDollars, }) } diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index 3865028cee6..b442ac0ce61 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -4,7 +4,10 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { getPlanPricing } from '@/lib/billing/core/billing' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' +import { + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, @@ -147,12 +150,17 @@ export async function getOrganizationBillingData( const planDollars = getPlanTierDollars(subscription.plan) if (planDollars > 0) { const memberIds = members.map((m) => m.userId) + const userBounds = await getOrgMemberRefreshBounds( + subscription.referenceId, + subscription.periodStart + ) const refreshConsumed = await computeDailyRefreshConsumed({ userIds: memberIds, periodStart: subscription.periodStart, periodEnd: subscription.periodEnd ?? null, planDollars, seats: subscription.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) totalCurrentUsage = Math.max(0, totalCurrentUsage - refreshConsumed) } diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index efc5cd0b3ee..2d1dde364d3 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -13,7 +13,10 @@ import { getHighestPrioritySubscription, type HighestPrioritySubscription, } from '@/lib/billing/core/plan' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' +import { + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isFree, isPaid, isPro } from '@/lib/billing/plan-helpers' import { canEditUsageLimit, @@ -214,12 +217,17 @@ export async function getUserUsageData(userId: string): Promise { if (planDollars > 0) { if (orgScoped) { if (orgMemberIds.length > 0) { + const userBounds = await getOrgMemberRefreshBounds( + subscription.referenceId, + billingPeriodStart + ) dailyRefreshConsumed = await computeDailyRefreshConsumed({ userIds: orgMemberIds, periodStart: billingPeriodStart, periodEnd: billingPeriodEnd, planDollars, seats: subscription.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) } } else { @@ -649,12 +657,18 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise 0 ? userBounds : undefined, }) return Math.max(0, rawCost - refreshConsumed) diff --git a/apps/sim/lib/billing/credits/daily-refresh.ts b/apps/sim/lib/billing/credits/daily-refresh.ts index 03d386d965b..48e13d7b2d8 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.ts @@ -12,15 +12,26 @@ */ import { db } from '@sim/db' -import { usageLog } from '@sim/db/schema' +import { member, usageLog, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, gte, inArray, lt, sql, sum } from 'drizzle-orm' +import { and, eq, gte, inArray, lt, or, sql, sum } from 'drizzle-orm' import { DAILY_REFRESH_RATE } from '@/lib/billing/constants' const logger = createLogger('DailyRefresh') const MS_PER_DAY = 86_400_000 +/** + * Optional per-user date window. `usageLog` rows outside + * `[userStart, userEnd)` are excluded from that user's contribution. + * Used to slice refresh around a mid-cycle org join so pre-join and + * post-join refresh are billed by the right subscription. + */ +export interface PerUserBounds { + userStart?: Date | null + userEnd?: Date | null +} + /** * Compute the total daily refresh credits consumed in the current billing period * using a single aggregating SQL query grouped by day offset. @@ -36,8 +47,9 @@ export async function computeDailyRefreshConsumed(params: { periodEnd?: Date | null planDollars: number seats?: number + userBounds?: Record }): Promise { - const { userIds, periodStart, periodEnd, planDollars, seats = 1 } = params + const { userIds, periodStart, periodEnd, planDollars, seats = 1, userBounds } = params if (planDollars <= 0 || userIds.length === 0) return 0 @@ -51,6 +63,39 @@ export async function computeDailyRefreshConsumed(params: { const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY) if (dayCount <= 0) return 0 + const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds + + const boundedClauses = userBounds + ? Object.entries(userBounds).flatMap(([userId, bounds]) => { + if (!userIds.includes(userId)) return [] + const effectiveStart = + bounds.userStart && bounds.userStart > periodStart ? bounds.userStart : periodStart + const effectiveEnd = bounds.userEnd && bounds.userEnd < cap ? bounds.userEnd : cap + if (effectiveEnd <= effectiveStart) return [] + return [ + and( + eq(usageLog.userId, userId), + gte(usageLog.createdAt, effectiveStart), + lt(usageLog.createdAt, effectiveEnd) + ), + ] + }) + : [] + + const rowFilters = + unboundedUsers.length > 0 + ? [ + and( + inArray(usageLog.userId, unboundedUsers), + gte(usageLog.createdAt, periodStart), + lt(usageLog.createdAt, cap) + ), + ...boundedClauses, + ] + : boundedClauses + + if (rowFilters.length === 0) return 0 + const rows = await db .select({ dayIndex: @@ -60,13 +105,7 @@ export async function computeDailyRefreshConsumed(params: { dayTotal: sum(usageLog.cost).as('day_total'), }) .from(usageLog) - .where( - and( - inArray(usageLog.userId, userIds), - gte(usageLog.createdAt, periodStart), - lt(usageLog.createdAt, cap) - ) - ) + .where(rowFilters.length === 1 ? rowFilters[0] : or(...rowFilters)) .groupBy(sql`day_index`) let totalConsumed = 0 @@ -81,6 +120,7 @@ export async function computeDailyRefreshConsumed(params: { days: dayCount, dailyRefreshDollars, totalConsumed, + hasUserBounds: Boolean(userBounds), }) return totalConsumed @@ -92,3 +132,25 @@ export async function computeDailyRefreshConsumed(params: { export function getDailyRefreshDollars(planDollars: number): number { return planDollars * DAILY_REFRESH_RATE } + +export async function getOrgMemberRefreshBounds( + organizationId: string, + periodStart: Date +): Promise> { + const rows = await db + .select({ + userId: member.userId, + snapshotAt: userStats.proPeriodCostSnapshotAt, + }) + .from(member) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(eq(member.organizationId, organizationId)) + + const bounds: Record = {} + for (const row of rows) { + if (row.snapshotAt && row.snapshotAt > periodStart) { + bounds[row.userId] = { userStart: row.snapshotAt } + } + } + return bounds +} diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index cbc3b6ee28e..e063ea47b63 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -17,9 +17,10 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' -import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { generateId } from '@/lib/core/utils/uuid' const logger = createLogger('OrganizationMembership') @@ -137,31 +138,28 @@ export async function restoreUserProSubscription(userId: string): Promise { + await tx + .update(subscriptionTable) + .set({ cancelAtPeriodEnd: false }) + .where(eq(subscriptionTable.id, personalPro.id)) + + if (personalPro.stripeSubscriptionId) { + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { + stripeSubscriptionId: personalPro.stripeSubscriptionId, + subscriptionId: personalPro.id, + reason: 'member-left-paid-org', + }) + } }) - } - - try { - await db - .update(subscriptionTable) - .set({ cancelAtPeriodEnd: false }) - .where(eq(subscriptionTable.id, personalPro.id)) result.restored = true - logger.info('Restored personal Pro subscription', { + logger.info('Restored personal Pro subscription (DB committed, Stripe queued)', { userId, subscriptionId: personalPro.id, }) } catch (dbError) { - logger.error('DB update failed when restoring personal Pro', { + logger.error('Failed to restore personal Pro subscription', { userId, subscriptionId: personalPro.id, error: dbError, @@ -192,6 +190,7 @@ export async function restoreUserProSubscription(userId: string): Promise 0) { + await tx + .update(organization) + .set({ + storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`, + }) + .where(eq(organization.id, organizationId)) + + await tx + .update(userStats) + .set({ storageUsedBytes: 0 }) + .where(eq(userStats.userId, userId)) + + logger.info('Transferred personal storage bytes to org pool on admin add', { + userId, + organizationId, + bytes: bytesToTransfer, + }) + } } }) diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 9fac8aef0b9..921b54821b6 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -2,20 +2,23 @@ import { db } from '@sim/db' import { member, organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, sql } from 'drizzle-orm' -import type Stripe from 'stripe' import { DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants' import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { calculateSubscriptionOverage, getPlanPricing } from '@/lib/billing/core/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' +import { + computeDailyRefreshConsumed, + getOrgMemberRefreshBounds, +} from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers' -import { requireStripeClient } from '@/lib/billing/stripe-client' import { hasUsableSubscriptionAccess, isOrgScopedSubscription, USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { env } from '@/lib/core/config/env' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' const logger = createLogger('ThresholdBilling') @@ -26,84 +29,6 @@ function parseDecimal(value: string | number | null | undefined): number { return Number.parseFloat(value.toString()) } -async function createAndFinalizeOverageInvoice( - stripe: ReturnType, - params: { - customerId: string - stripeSubscriptionId: string - amountCents: number - description: string - itemDescription: string - metadata: Record - idempotencyKey: string - } -): Promise { - const getPaymentMethodId = ( - pm: string | Stripe.PaymentMethod | null | undefined - ): string | undefined => (typeof pm === 'string' ? pm : pm?.id) - - let defaultPaymentMethod: string | undefined - try { - const stripeSub = await stripe.subscriptions.retrieve(params.stripeSubscriptionId) - const subDpm = getPaymentMethodId(stripeSub.default_payment_method) - if (subDpm) { - defaultPaymentMethod = subDpm - } else { - const custObj = await stripe.customers.retrieve(params.customerId) - if (custObj && !('deleted' in custObj)) { - const cust = custObj as Stripe.Customer - const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method) - if (custDpm) defaultPaymentMethod = custDpm - } - } - } catch (e) { - logger.error('Failed to retrieve subscription or customer', { error: e }) - } - - const invoice = await stripe.invoices.create( - { - customer: params.customerId, - collection_method: 'charge_automatically', - auto_advance: false, - description: params.description, - metadata: params.metadata, - ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), - }, - { idempotencyKey: `${params.idempotencyKey}-invoice` } - ) - - await stripe.invoiceItems.create( - { - customer: params.customerId, - invoice: invoice.id, - amount: params.amountCents, - currency: 'usd', - description: params.itemDescription, - metadata: params.metadata, - }, - { idempotencyKey: params.idempotencyKey } - ) - - if (invoice.id) { - const finalized = await stripe.invoices.finalizeInvoice(invoice.id) - - if (finalized.status === 'open' && finalized.id) { - try { - await stripe.invoices.pay(finalized.id, { - payment_method: defaultPaymentMethod, - }) - } catch (payError) { - logger.error('Failed to auto-pay threshold overage invoice', { - error: payError, - invoiceId: finalized.id, - }) - } - } - } - - return invoice.id || '' -} - export async function checkAndBillOverageThreshold(userId: string): Promise { try { const threshold = OVERAGE_THRESHOLD @@ -173,6 +98,23 @@ export async function checkAndBillOverageThreshold(userId: string): Promise 0) { creditsApplied = Math.min(creditBalance, amountToBill) - // Update credit balance within the transaction await tx .update(userStats) .set({ @@ -197,7 +138,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise 0) { const allMemberIds = members.map((m) => m.userId) + const userBounds = await getOrgMemberRefreshBounds( + organizationId, + orgSubscription.periodStart + ) dailyRefreshDeduction = await computeDailyRefreshConsumed({ userIds: allMemberIds, periodStart: orgSubscription.periodStart, periodEnd: orgSubscription.periodEnd ?? null, planDollars, seats: orgSubscription.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) } } @@ -441,13 +367,24 @@ export async function checkAndBillOrganizationOverageThreshold( return } - // Apply credits to reduce the amount to bill (use locked org's balance) + // Validate Stripe identifiers BEFORE mutating credits/trackers. + const stripeSubscriptionId = orgSubscription.stripeSubscriptionId + if (!stripeSubscriptionId) { + logger.error('No Stripe subscription ID for organization', { organizationId }) + return + } + + const customerId = orgSubscription.stripeCustomerId + if (!customerId) { + logger.error('No Stripe customer ID for organization', { organizationId }) + return + } + let amountToBill = unbilledOverage let creditsApplied = 0 if (orgCreditBalance > 0) { creditsApplied = Math.min(orgCreditBalance, amountToBill) - // Update credit balance within the transaction await tx .update(organization) .set({ @@ -464,7 +401,7 @@ export async function checkAndBillOrganizationOverageThreshold( }) } - // If credits covered everything, just update the billed amount but don't create invoice + // If credits covered everything, bump billed tracker but don't enqueue Stripe invoice. if (amountToBill <= 0) { await tx .update(userStats) @@ -481,19 +418,6 @@ export async function checkAndBillOrganizationOverageThreshold( return } - const stripeSubscriptionId = orgSubscription.stripeSubscriptionId - if (!stripeSubscriptionId) { - logger.error('No Stripe subscription ID for organization', { organizationId }) - return - } - - const stripe = requireStripeClient() - const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) - const customerId = - typeof stripeSubscription.customer === 'string' - ? stripeSubscription.customer - : stripeSubscription.customer.id - const periodEnd = orgSubscription.periodEnd ? Math.floor(orgSubscription.periodEnd.getTime() / 1000) : Math.floor(Date.now() / 1000) @@ -501,23 +425,24 @@ export async function checkAndBillOrganizationOverageThreshold( const amountCents = Math.round(amountToBill * 100) const totalOverageCents = Math.round(currentOverage * 100) - const idempotencyKey = `threshold-overage-org:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}` - - logger.info('Creating organization threshold overage invoice', { - organizationId, - amountToBill, - creditsApplied, - billingPeriod, - }) - - const cents = amountCents + // Bump billed tracker and enqueue Stripe invoice atomically. + // See user-path above for the full retry-invariant reasoning. + await tx + .update(userStats) + .set({ + billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`, + }) + .where(eq(userStats.userId, owner.userId)) - const invoiceId = await createAndFinalizeOverageInvoice(stripe, { + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, { customerId, stripeSubscriptionId, - amountCents: cents, + amountCents, description: `Team threshold overage billing – ${billingPeriod}`, itemDescription: `Team usage overage ($${amountToBill.toFixed(2)})`, + billingPeriod, + invoiceIdemKeyStem: `threshold-overage-org-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`, + itemIdemKeyStem: `threshold-overage-org-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`, metadata: { type: 'overage_threshold_billing_org', organizationId, @@ -525,23 +450,15 @@ export async function checkAndBillOrganizationOverageThreshold( billingPeriod, totalOverageAtTimeOfBilling: currentOverage.toFixed(2), }, - idempotencyKey, }) - await tx - .update(userStats) - .set({ - billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`, - }) - .where(eq(userStats.userId, owner.userId)) - - logger.info('Successfully created and finalized organization threshold overage invoice', { + logger.info('Queued organization threshold overage invoice for Stripe', { organizationId, ownerId: owner.userId, creditsApplied, amountBilled: amountToBill, totalProcessed: unbilledOverage, - invoiceId, + billingPeriod, }) }) } catch (error) { diff --git a/apps/sim/lib/billing/webhooks/idempotency.ts b/apps/sim/lib/billing/webhooks/idempotency.ts new file mode 100644 index 00000000000..e9613168af0 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/idempotency.ts @@ -0,0 +1,39 @@ +import { IdempotencyService } from '@/lib/core/idempotency/service' + +/** + * Idempotency service for Stripe webhook handlers. + * + * Stripe delivers webhook events at-least-once and retries failed + * deliveries for up to 3 days. Handlers that perform non-idempotent work + * (crediting accounts, removing credits, resetting usage trackers, etc.) + * must be wrapped in a claim so duplicate deliveries are collapsed to a + * single execution. + * + * Storage is **forced to Postgres** regardless of whether Redis is + * configured. Billing handlers mutate `user_stats` / `organization` / + * `subscription` rows via DB transactions — keeping the idempotency + * record in the same Postgres closes the narrow window where the + * operation commits but a Redis `storeResult` fails, which would cause + * Stripe's next retry to re-run the money-affecting work. The latency + * cost (1–5 ms per claim/store) is invisible on webhook responses, and + * volume is low enough (roughly one event per customer per billing + * cycle) that DB storage scales comfortably. + * + * `retryFailures: true` means a thrown handler releases the claim so + * Stripe's next retry runs from scratch — without it, one transient + * failure would poison the key for the whole TTL window. + * + * TTL of 7 days is slightly longer than Stripe's 3-day retry horizon so + * late retries still dedupe against completed work. Rows past their TTL + * are handled two ways: `atomicallyClaimDb` reclaims stale rows inline + * via `ON CONFLICT DO UPDATE WHERE created_at < expired_before` (so + * correctness does not depend on cleanup running), and the external + * cleanup cron (scheduled from the infra repo) hits + * `/api/webhooks/cleanup/idempotency` to bound table size. + */ +export const stripeWebhookIdempotency = new IdempotencyService({ + namespace: 'stripe-webhook', + ttlSeconds: 60 * 60 * 24 * 7, + retryFailures: true, + forceStorage: 'database', +}) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 12490207b96..1975a460a8c 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -8,15 +8,16 @@ import { userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import type Stripe from 'stripe' import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' -import { addCredits, getCreditBalanceForEntity, removeCredits } from '@/lib/billing/credits/balance' +import { addCredits, getCreditBalanceForEntity } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership' import { isEnterprise } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' @@ -409,8 +410,8 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe .set({ lastPeriodCost: current, lastPeriodCopilotCost: currentCopilot, - currentPeriodCost: '0', - currentPeriodCopilotCost: '0', + currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - ${current}::decimal)`, + currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - ${currentCopilot}::decimal)`, billedOverageThisPeriod: '0', }) .where(eq(userStats.userId, m.userId)) @@ -432,7 +433,7 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe .where(eq(userStats.userId, sub.referenceId)) .limit(1) if (currentStats.length > 0) { - const current = Number.parseFloat(currentStats[0].current?.toString() || '0') + const current = currentStats[0].current || '0' const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0') const currentCopilot = currentStats[0].currentCopilot || '0' @@ -447,19 +448,22 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe lastPeriodCost: snapshot.toString(), lastPeriodCopilotCost: '0', proPeriodCostSnapshot: '0', + proPeriodCostSnapshotAt: null, billedOverageThisPeriod: '0', }) .where(eq(userStats.userId, sub.referenceId)) } else { - const totalLastPeriod = (current + snapshot).toString() + const totalLastPeriod = (Number.parseFloat(current) + snapshot).toString() + // Delta-reset for the same reason as the org branch above. await db .update(userStats) .set({ lastPeriodCost: totalLastPeriod, lastPeriodCopilotCost: currentCopilot, - currentPeriodCost: '0', - currentPeriodCopilotCost: '0', + currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - ${current}::decimal)`, + currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - ${currentCopilot}::decimal)`, proPeriodCostSnapshot: '0', + proPeriodCostSnapshotAt: null, billedOverageThisPeriod: '0', }) .where(eq(userStats.userId, sub.referenceId)) @@ -487,87 +491,127 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise 0) { - const sub = subscription[0] - const newCreditBalance = await getCreditBalanceForEntity(entityType, entityId) - await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance) + if (!invoice.id) { + logger.error('Credit purchase invoice missing id, cannot dedupe', { + metadata: invoice.metadata, + }) + return } - logger.info('Credit purchase completed via webhook', { - invoiceId: invoice.id, - entityType, - entityId, - amount, - purchasedBy, - }) - - try { - const newBalance = await getCreditBalanceForEntity(entityType, entityId) - let recipients: Array<{ email: string; name: string | null }> = [] + // Idempotent apply: duplicate Stripe deliveries collapse to a single + // execution. On exception the key is released (retryFailures: true) + // so the next Stripe retry runs from scratch. On success, subsequent + // deliveries short-circuit with the cached result. + // + // CRITICAL: everything after `addCredits` must be either idempotent or + // wrapped in try/catch that does not rethrow. Otherwise a failure + // after credits commit would release the key and the retry would + // double-credit. `setUsageLimitForCredits` and the email are both + // best-effort and wrapped; the subscription lookup before them is a + // read, safe to rerun. + await stripeWebhookIdempotency.executeWithIdempotency('credit-purchase', invoice.id, async () => { + await addCredits(entityType, entityId, amount) + + try { + const subscription = await db + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.referenceId, entityId)) + .limit(1) - if (entityType === 'organization') { - const members = await db - .select({ userId: member.userId, role: member.role }) - .from(member) - .where(eq(member.organizationId, entityId)) + if (subscription.length > 0) { + const sub = subscription[0] + const newCreditBalance = await getCreditBalanceForEntity(entityType, entityId) + await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance) + } + } catch (limitError) { + // Limit bump is best-effort. Customer already got credits; if the + // cap doesn't auto-raise they can edit it themselves or another + // credit purchase will rebase it. Do NOT rethrow — that would + // release the idempotency claim and double-credit on retry. + logger.error('Failed to update usage limit after credit purchase', { + invoiceId: invoice.id, + entityType, + entityId, + error: limitError, + }) + } - const ownerAdminIds = members - .filter((m) => m.role === 'owner' || m.role === 'admin') - .map((m) => m.userId) + logger.info('Credit purchase completed via webhook', { + invoiceId: invoice.id, + entityType, + entityId, + amount, + purchasedBy, + }) - if (ownerAdminIds.length > 0) { - recipients = await db + try { + const newBalance = await getCreditBalanceForEntity(entityType, entityId) + let recipients: Array<{ email: string; name: string | null }> = [] + + if (entityType === 'organization') { + const members = await db + .select({ userId: member.userId, role: member.role }) + .from(member) + .where(eq(member.organizationId, entityId)) + + const ownerAdminIds = members + .filter((m) => m.role === 'owner' || m.role === 'admin') + .map((m) => m.userId) + + if (ownerAdminIds.length > 0) { + recipients = await db + .select({ email: user.email, name: user.name }) + .from(user) + .where(inArray(user.id, ownerAdminIds)) + } + } else if (purchasedBy) { + const users = await db .select({ email: user.email, name: user.name }) .from(user) - .where(inArray(user.id, ownerAdminIds)) - } - } else if (purchasedBy) { - const users = await db - .select({ email: user.email, name: user.name }) - .from(user) - .where(eq(user.id, purchasedBy)) - .limit(1) + .where(eq(user.id, purchasedBy)) + .limit(1) - recipients = users - } + recipients = users + } - for (const recipient of recipients) { - if (!recipient.email) continue + for (const recipient of recipients) { + if (!recipient.email) continue - const emailHtml = await renderCreditPurchaseEmail({ - userName: recipient.name || undefined, - amount, - newBalance, - }) + const emailHtml = await renderCreditPurchaseEmail({ + userName: recipient.name || undefined, + amount, + newBalance, + }) - await sendEmail({ - to: recipient.email, - subject: getEmailSubject('credit-purchase'), - html: emailHtml, - emailType: 'transactional', - }) + await sendEmail({ + to: recipient.email, + subject: getEmailSubject('credit-purchase'), + html: emailHtml, + emailType: 'transactional', + }) - logger.info('Sent credit purchase confirmation email', { - email: recipient.email, + logger.info('Sent credit purchase confirmation email', { + email: recipient.email, + invoiceId: invoice.id, + }) + } + } catch (emailError) { + // Emails are best-effort — a failure here should NOT release the + // claim (otherwise Stripe retries would re-credit the user). + logger.error('Failed to send credit purchase emails', { + emailError, invoiceId: invoice.id, }) } - } catch (emailError) { - logger.error('Failed to send credit purchase emails', { emailError, invoiceId: invoice.id }) - } + + return { ok: true } + }) } /** @@ -764,7 +808,6 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { export async function handleInvoiceFinalized(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - // Only run for subscription renewal invoices (cycle boundary) const subscription = invoice.parent?.subscription_details?.subscription const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id if (!stripeSubscriptionId) { @@ -784,151 +827,235 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { if (records.length === 0) return const sub = records[0] - // Enterprise plans have no overages - reset usage and exit if (isEnterprise(sub.plan)) { await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) return } - const stripe = requireStripeClient() - const periodEnd = - invoice.lines?.data?.[0]?.period?.end || invoice.period_end || Math.floor(Date.now() / 1000) - const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) + await stripeWebhookIdempotency.executeWithIdempotency( + 'invoice-finalized', + event.id, + async () => { + const stripe = requireStripeClient() + const periodEnd = + invoice.lines?.data?.[0]?.period?.end || + invoice.period_end || + Math.floor(Date.now() / 1000) + const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) + + const totalOverage = await calculateSubscriptionOverage(sub) + + const entityType = (await isSubscriptionOrgScoped(sub)) ? 'organization' : 'user' + const entityId = sub.referenceId + + // Resolve the userStats row that holds the `billedOverageThisPeriod` + // tracker. Org subs: the owner's row. Personal: the user's own row. + // Throw if an org has no owner — returning early would cache a + // "successful" no-op, and the next cycle's tracker would still + // reflect this cycle's billed amount, breaking future overage math. + let trackerUserId: string + if (entityType === 'organization') { + const ownerRows = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, entityId), eq(member.role, 'owner'))) + .limit(1) + const ownerId = ownerRows[0]?.userId + if (!ownerId) { + throw new Error( + `Organization ${entityId} has no owner member; cannot process invoice finalization` + ) + } + trackerUserId = ownerId + } else { + trackerUserId = entityId + } - // Compute overage (only for team and pro plans), before resetting usage - const totalOverage = await calculateSubscriptionOverage(sub) + // Phase 1 — atomic commit. Lock the tracker row first so we read + // `billedOverageThisPeriod` serialized against concurrent events; + // then read the credit balance, decrement it, and bump the + // tracker to `totalOverage`. On retry, the locked re-read sees + // `billed == totalOverage` → `remaining == 0` → credit removal + // skipped. That's the invariant preventing double-deduction. + const phase1 = await db.transaction(async (tx) => { + const trackerRows = await tx + .select({ billed: userStats.billedOverageThisPeriod }) + .from(userStats) + .where(eq(userStats.userId, trackerUserId)) + .for('update') + .limit(1) + + const billedInTx = + trackerRows.length > 0 ? Number.parseFloat(trackerRows[0].billed?.toString() || '0') : 0 + const remaining = Math.max(0, totalOverage - billedInTx) + + if (remaining === 0) { + return { billedInTx, applied: 0, billed: 0, remaining: 0 } + } - // Get already-billed overage from threshold billing - const billedOverage = await getBilledOverageForSubscription(sub) + const lockedBalance = + entityType === 'organization' + ? await tx + .select({ creditBalance: organization.creditBalance }) + .from(organization) + .where(eq(organization.id, entityId)) + .for('update') + .limit(1) + : await tx + .select({ creditBalance: userStats.creditBalance }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .for('update') + .limit(1) + + const creditBalance = + lockedBalance.length > 0 + ? Number.parseFloat(lockedBalance[0].creditBalance?.toString() || '0') + : 0 + + const applied = Math.min(creditBalance, remaining) + const billed = remaining - applied + + if (applied > 0) { + if (entityType === 'organization') { + await tx + .update(organization) + .set({ + creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${applied})`, + }) + .where(eq(organization.id, entityId)) + } else { + await tx + .update(userStats) + .set({ + creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${applied})`, + }) + .where(eq(userStats.userId, entityId)) + } + } - // Only bill the remaining unbilled overage - let remainingOverage = Math.max(0, totalOverage - billedOverage) + await tx + .update(userStats) + .set({ billedOverageThisPeriod: totalOverage.toString() }) + .where(eq(userStats.userId, trackerUserId)) - // Apply credits to reduce overage at end of cycle - let creditsApplied = 0 - if (remainingOverage > 0) { - const entityType = (await isSubscriptionOrgScoped(sub)) ? 'organization' : 'user' - const entityId = sub.referenceId - const creditBalance = await getCreditBalanceForEntity(entityType, entityId) + return { billedInTx, applied, billed, remaining } + }) - if (creditBalance > 0) { - creditsApplied = Math.min(creditBalance, remainingOverage) - await removeCredits(entityType, entityId, creditsApplied) - remainingOverage = remainingOverage - creditsApplied + const creditsApplied = phase1.applied + const amountToBillStripe = phase1.billed - logger.info('Applied credits to reduce overage at cycle end', { + logger.info('Invoice finalized overage calculation', { subscriptionId: sub.id, - creditBalance, + totalOverage, + billedOverageBeforeTx: phase1.billedInTx, creditsApplied, - remainingOverageAfterCredits: remainingOverage, + amountToBillStripe, + billingPeriod, }) - } - } - logger.info('Invoice finalized overage calculation', { - subscriptionId: sub.id, - totalOverage, - billedOverage, - creditsApplied, - remainingOverage, - billingPeriod, - }) - - if (remainingOverage > 0) { - const customerId = String(invoice.customer) - const cents = Math.round(remainingOverage * 100) - const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + // Phase 2 — Stripe invoice. Runs outside any DB transaction. + // Every call uses a deterministic idempotency key so retries + // converge on the same invoice object: re-create returns the + // existing draft, re-finalize no-ops on an already-finalized + // invoice, re-pay no-ops on an already-paid invoice. + if (amountToBillStripe > 0) { + const customerId = String(invoice.customer) + const cents = Math.round(amountToBillStripe * 100) + const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + + const getPaymentMethodId = ( + pm: string | Stripe.PaymentMethod | null | undefined + ): string | undefined => (typeof pm === 'string' ? pm : pm?.id) + + let collectionMethod: 'charge_automatically' | 'send_invoice' = 'charge_automatically' + let defaultPaymentMethod: string | undefined + try { + const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId) + if (stripeSub.collection_method === 'send_invoice') { + collectionMethod = 'send_invoice' + } + const subDpm = getPaymentMethodId(stripeSub.default_payment_method) + if (subDpm) { + defaultPaymentMethod = subDpm + } else if (collectionMethod === 'charge_automatically') { + const custObj = await stripe.customers.retrieve(customerId) + if (custObj && !('deleted' in custObj)) { + const cust = custObj as Stripe.Customer + const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method) + if (custDpm) defaultPaymentMethod = custDpm + } + } + } catch (e) { + logger.error('Failed to retrieve subscription or customer', { error: e }) + } - // Inherit billing settings from the Stripe subscription/customer for autopay - const getPaymentMethodId = ( - pm: string | Stripe.PaymentMethod | null | undefined - ): string | undefined => (typeof pm === 'string' ? pm : pm?.id) + const overageInvoice = await stripe.invoices.create( + { + customer: customerId, + collection_method: collectionMethod, + auto_advance: false, + ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), + metadata: { + type: 'overage_billing', + billingPeriod, + subscriptionId: stripeSubscriptionId, + }, + }, + { idempotencyKey: invoiceIdemKey } + ) - let collectionMethod: 'charge_automatically' | 'send_invoice' = 'charge_automatically' - let defaultPaymentMethod: string | undefined - try { - const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId) - if (stripeSub.collection_method === 'send_invoice') { - collectionMethod = 'send_invoice' - } - const subDpm = getPaymentMethodId(stripeSub.default_payment_method) - if (subDpm) { - defaultPaymentMethod = subDpm - } else if (collectionMethod === 'charge_automatically') { - const custObj = await stripe.customers.retrieve(customerId) - if (custObj && !('deleted' in custObj)) { - const cust = custObj as Stripe.Customer - const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method) - if (custDpm) defaultPaymentMethod = custDpm - } - } - } catch (e) { - logger.error('Failed to retrieve subscription or customer', { error: e }) - } + await stripe.invoiceItems.create( + { + customer: customerId, + invoice: overageInvoice.id, + amount: cents, + currency: 'usd', + description: `Usage Based Overage – ${billingPeriod}`, + metadata: { + type: 'overage_billing', + billingPeriod, + subscriptionId: stripeSubscriptionId, + }, + }, + { idempotencyKey: itemIdemKey } + ) - // Create a draft invoice first so we can attach the item directly - const overageInvoice = await stripe.invoices.create( - { - customer: customerId, - collection_method: collectionMethod, - auto_advance: false, - ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), - metadata: { - type: 'overage_billing', - billingPeriod, - subscriptionId: stripeSubscriptionId, - }, - }, - { idempotencyKey: invoiceIdemKey } - ) - - // Attach the item to this invoice - await stripe.invoiceItems.create( - { - customer: customerId, - invoice: overageInvoice.id, - amount: cents, - currency: 'usd', - description: `Usage Based Overage – ${billingPeriod}`, - metadata: { - type: 'overage_billing', - billingPeriod, - subscriptionId: stripeSubscriptionId, - }, - }, - { idempotencyKey: itemIdemKey } - ) - - // Finalize to trigger autopay (if charge_automatically and a PM is present) - const draftId = overageInvoice.id - if (typeof draftId !== 'string' || draftId.length === 0) { - logger.error('Stripe created overage invoice without id; aborting finalize') - } else { - const finalized = await stripe.invoices.finalizeInvoice(draftId) - // Some manual invoices may remain open after finalize; ensure we pay immediately when possible - if (collectionMethod === 'charge_automatically' && finalized.status === 'open') { - try { - const payId = finalized.id - if (typeof payId !== 'string' || payId.length === 0) { - logger.error('Finalized invoice missing id') - throw new Error('Finalized invoice missing id') + const draftId = overageInvoice.id + if (typeof draftId !== 'string' || draftId.length === 0) { + logger.error('Stripe created overage invoice without id; aborting finalize') + } else { + const finalized = await stripe.invoices.finalizeInvoice(draftId) + if (collectionMethod === 'charge_automatically' && finalized.status === 'open') { + try { + const payId = finalized.id + if (typeof payId !== 'string' || payId.length === 0) { + logger.error('Finalized invoice missing id') + throw new Error('Finalized invoice missing id') + } + await stripe.invoices.pay(payId, { + payment_method: defaultPaymentMethod, + }) + } catch (payError) { + logger.error('Failed to auto-pay overage invoice', { + error: payError, + invoiceId: finalized.id, + }) + } } - await stripe.invoices.pay(payId, { - payment_method: defaultPaymentMethod, - }) - } catch (payError) { - logger.error('Failed to auto-pay overage invoice', { - error: payError, - invoiceId: finalized.id, - }) } } - } - } - // Finally, reset usage for this subscription after overage handling - await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + // Phase 3 — reset usage for the new period. Clears trackers and + // rolls `currentPeriodCost` forward by delta. Idempotent on its + // own (delta subtraction of a value that's already been + // subtracted is a no-op). + await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + + return { totalOverage, creditsApplied, amountToBillStripe } + } + ) } catch (error) { logger.error('Failed to handle invoice finalized', { error }) throw error diff --git a/apps/sim/lib/billing/webhooks/outbox-handlers.ts b/apps/sim/lib/billing/webhooks/outbox-handlers.ts new file mode 100644 index 00000000000..9effe40fb7e --- /dev/null +++ b/apps/sim/lib/billing/webhooks/outbox-handlers.ts @@ -0,0 +1,203 @@ +import { db } from '@sim/db' +import { subscription as subscriptionTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type Stripe from 'stripe' +import { requireStripeClient } from '@/lib/billing/stripe-client' +import type { OutboxHandler } from '@/lib/core/outbox/service' + +const logger = createLogger('BillingOutboxHandlers') + +export const OUTBOX_EVENT_TYPES = { + /** + * Sync a subscription's `cancel_at_period_end` flag from our DB to + * Stripe. The handler reads the current DB value at processing time + * — so rapid cancel→uncancel→cancel sequences always converge on + * the last-committed DB state regardless of outbox ordering. Callers + * enqueue this event after every DB change to `cancelAtPeriodEnd`. + */ + STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end', + STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice', +} as const + +export interface StripeSyncCancelAtPeriodEndPayload { + stripeSubscriptionId: string + /** The DB subscription row id — also our source-of-truth pointer. */ + subscriptionId: string + /** Optional: reason this was enqueued — e.g. 'member-joined-paid-org'. */ + reason?: string +} + +export interface StripeThresholdOverageInvoicePayload { + customerId: string + stripeSubscriptionId: string + amountCents: number + description: string + itemDescription: string + billingPeriod: string + /** Stripe idempotency key stem — we append the outbox event id for per-retry safety. */ + invoiceIdemKeyStem: string + itemIdemKeyStem: string + metadata?: Record +} + +const stripeSyncCancelAtPeriodEnd: OutboxHandler = async ( + payload, + ctx +) => { + // Read the DB value at processing time (not at enqueue time). This + // makes the handler idempotent across racing enqueues: multiple + // events for the same subscription all push whatever the DB + // currently says, converging on the last committed value. + const rows = await db + .select({ cancelAtPeriodEnd: subscriptionTable.cancelAtPeriodEnd }) + .from(subscriptionTable) + .where(eq(subscriptionTable.id, payload.subscriptionId)) + .limit(1) + + if (rows.length === 0) { + logger.warn('Subscription not found when syncing cancel_at_period_end', { + subscriptionId: payload.subscriptionId, + }) + return + } + + const desiredValue = Boolean(rows[0].cancelAtPeriodEnd) + const stripe = requireStripeClient() + await stripe.subscriptions.update( + payload.stripeSubscriptionId, + { cancel_at_period_end: desiredValue }, + { idempotencyKey: `outbox:${ctx.eventId}` } + ) + logger.info('Synced cancel_at_period_end from DB to Stripe', { + eventId: ctx.eventId, + stripeSubscriptionId: payload.stripeSubscriptionId, + subscriptionId: payload.subscriptionId, + desiredValue, + reason: payload.reason, + }) +} + +/** + * Resolve the payment method to use for auto-collection. Matches the + * pre-refactor behavior: subscription PM first, customer PM second. + * Without this, Stripe falls back to customer PM only when the invoice + * is attached to a subscription — but we create an ad-hoc invoice not + * linked to the subscription, so we resolve explicitly. + */ +async function resolveDefaultPaymentMethod( + stripe: Stripe, + stripeSubscriptionId: string, + customerId: string +): Promise { + const toId = (pm: string | Stripe.PaymentMethod | null | undefined): string | undefined => + typeof pm === 'string' ? pm : pm?.id + + try { + const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId) + const subPm = toId(sub.default_payment_method) + if (subPm) return subPm + + const customer = await stripe.customers.retrieve(customerId) + if (customer && !('deleted' in customer)) { + return toId((customer as Stripe.Customer).invoice_settings?.default_payment_method) + } + } catch (error) { + logger.warn('Failed to resolve default payment method', { + stripeSubscriptionId, + customerId, + error: error instanceof Error ? error.message : error, + }) + } + + return undefined +} + +const stripeThresholdOverageInvoice: OutboxHandler = async ( + payload, + ctx +) => { + const stripe = requireStripeClient() + + // Resolve default PM from (subscription → customer) so Stripe can + // auto-collect when the invoice finalizes. Without this, an ad-hoc + // invoice (no subscription link) falls back to customer-level PM + // only, which may not be set for customers onboarded via Checkout + // Subscription flows. + const defaultPaymentMethod = await resolveDefaultPaymentMethod( + stripe, + payload.stripeSubscriptionId, + payload.customerId + ) + + // Compose Stripe idempotency keys from caller-provided stem + outbox + // event id so retries of the SAME outbox event collapse on Stripe's + // side. + const invoiceIdemKey = `${payload.invoiceIdemKeyStem}:${ctx.eventId}` + const itemIdemKey = `${payload.itemIdemKeyStem}:${ctx.eventId}` + + // `auto_advance: false` + explicit finalize mirrors pre-refactor + // behavior: we control exactly when the invoice finalizes, so it + // doesn't silently convert to paid/open on Stripe's schedule while + // our retry state is still in flight. + const invoice = await stripe.invoices.create( + { + customer: payload.customerId, + collection_method: 'charge_automatically', + auto_advance: false, + description: payload.description, + metadata: payload.metadata, + ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), + }, + { idempotencyKey: invoiceIdemKey } + ) + + if (!invoice.id) { + throw new Error('Stripe returned invoice without id') + } + + await stripe.invoiceItems.create( + { + customer: payload.customerId, + invoice: invoice.id, + amount: payload.amountCents, + currency: 'usd', + description: payload.itemDescription, + metadata: payload.metadata, + }, + { idempotencyKey: itemIdemKey } + ) + + const finalized = await stripe.invoices.finalizeInvoice(invoice.id) + + // If Stripe didn't auto-charge on finalize (e.g. `open` status with + // a known PM), attempt payment explicitly. Payment failures are + // non-fatal to the handler — the invoice is finalized and will + // retry charging via Stripe's own dunning. + if (finalized.status === 'open' && finalized.id && defaultPaymentMethod) { + try { + await stripe.invoices.pay(finalized.id, { payment_method: defaultPaymentMethod }) + } catch (payError) { + logger.warn('Auto-pay failed for threshold overage invoice — Stripe dunning will retry', { + invoiceId: finalized.id, + error: payError instanceof Error ? payError.message : payError, + }) + } + } + + logger.info('Created threshold overage invoice via outbox', { + eventId: ctx.eventId, + invoiceId: invoice.id, + customerId: payload.customerId, + amountCents: payload.amountCents, + billingPeriod: payload.billingPeriod, + defaultPaymentMethod: defaultPaymentMethod ? 'resolved' : 'none', + }) +} + +export const billingOutboxHandlers = { + [OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END]: + stripeSyncCancelAtPeriodEnd as OutboxHandler, + [OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE]: + stripeThresholdOverageInvoice as OutboxHandler, +} as const diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 369d24cd1ff..e49a1ee04bd 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -9,6 +9,7 @@ import { restoreUserProSubscription } from '@/lib/billing/organizations/membersh import { isEnterprise, isPaid, isPro } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency' import { getBilledOverageForSubscription, resetUsageForSubscription, @@ -175,194 +176,201 @@ export async function handleSubscriptionCreated(subscriptionData: { } /** - * Handle subscription deletion/cancellation - bill for final period overages - * This fires when a subscription reaches its cancel_at_period_end date or is cancelled immediately + * Handle subscription deletion/cancellation — bill for final period + * overages, reset usage, restore member Pros, and clean up the org. + * + * Wrapped in `stripeWebhookIdempotency` keyed by Stripe `event.id` so + * that duplicate webhook deliveries collapse to a single execution. The + * three failure-prone side effects each have their own recovery story: + * - Final Stripe invoice: created with a deterministic idempotency key, + * so re-create returns the existing invoice on retry + * - `resetUsageForSubscription`: delta-based reset, near-idempotent + * - `cleanupOrganizationSubscription`: delete is idempotent (ON NOT + * EXISTS); Pro restore flips `cancelAtPeriodEnd=false`, idempotent + * If any step throws, `retryFailures: true` releases the claim so + * Stripe's next retry runs from scratch and recovers. */ -export async function handleSubscriptionDeleted(subscription: { - id: string - plan: string | null - referenceId: string - stripeSubscriptionId: string | null - seats?: number | null -}) { - try { - const stripeSubscriptionId = subscription.stripeSubscriptionId || '' - - logger.info('Processing subscription deletion', { - stripeSubscriptionId, - subscriptionId: subscription.id, - }) - - // Calculate overage for the final billing period - const totalOverage = await calculateSubscriptionOverage(subscription) - const stripe = requireStripeClient() - - // Enterprise plans have no overages - reset usage and cleanup org - if (isEnterprise(subscription.plan)) { - await resetUsageForSubscription({ - plan: subscription.plan, - referenceId: subscription.referenceId, - }) - - const { restoredProCount, membersSynced, organizationDeleted } = - await cleanupOrganizationSubscription(subscription.referenceId) - - logger.info('Successfully processed enterprise subscription cancellation', { - subscriptionId: subscription.id, - stripeSubscriptionId, - restoredProCount, - organizationDeleted, - membersSynced, - }) - - captureServerEvent(subscription.referenceId, 'subscription_cancelled', { - plan: subscription.plan ?? 'unknown', - reference_id: subscription.referenceId, - }) - - return - } +export async function handleSubscriptionDeleted( + subscription: { + id: string + plan: string | null + referenceId: string + stripeSubscriptionId: string | null + seats?: number | null + }, + stripeEventId?: string +) { + const stripeSubscriptionId = subscription.stripeSubscriptionId || '' + + logger.info('Processing subscription deletion', { + stripeEventId, + stripeSubscriptionId, + subscriptionId: subscription.id, + }) + + // Fall back to the subscription DB id when we don't have an event id + // (e.g. called outside the Stripe webhook context). Still dedupes a + // single subscription's deletion, just not event-granular. + const idempotencyIdentifier = stripeEventId ?? `sub:${subscription.id}` - // Get already-billed overage from threshold billing - const billedOverage = await getBilledOverageForSubscription(subscription) + try { + await stripeWebhookIdempotency.executeWithIdempotency( + 'subscription-deleted', + idempotencyIdentifier, + async () => { + const totalOverage = await calculateSubscriptionOverage(subscription) + const stripe = requireStripeClient() + + // Enterprise plans have no overages — reset usage and cleanup org + if (isEnterprise(subscription.plan)) { + await resetUsageForSubscription({ + plan: subscription.plan, + referenceId: subscription.referenceId, + }) + + const { restoredProCount, membersSynced, organizationDeleted } = + await cleanupOrganizationSubscription(subscription.referenceId) + + logger.info('Successfully processed enterprise subscription cancellation', { + subscriptionId: subscription.id, + stripeSubscriptionId, + restoredProCount, + organizationDeleted, + membersSynced, + }) + + captureServerEvent(subscription.referenceId, 'subscription_cancelled', { + plan: subscription.plan ?? 'unknown', + reference_id: subscription.referenceId, + }) + + return { totalOverage: 0, kind: 'enterprise' as const } + } - // Only bill the remaining unbilled overage - const remainingOverage = Math.max(0, totalOverage - billedOverage) + const billedOverage = await getBilledOverageForSubscription(subscription) + const remainingOverage = Math.max(0, totalOverage - billedOverage) - logger.info('Subscription deleted overage calculation', { - subscriptionId: subscription.id, - totalOverage, - billedOverage, - remainingOverage, - }) + logger.info('Subscription deleted overage calculation', { + subscriptionId: subscription.id, + totalOverage, + billedOverage, + remainingOverage, + }) - // Create final overage invoice if needed - if (remainingOverage > 0 && stripeSubscriptionId) { - const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) - const customerId = stripeSubscription.customer as string - const cents = Math.round(remainingOverage * 100) - - // Use the subscription end date for the billing period - const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000) - const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7) - - const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - - try { - // Create a one-time invoice for the final overage - const overageInvoice = await stripe.invoices.create( - { - customer: customerId, - collection_method: 'charge_automatically', - auto_advance: true, // Auto-finalize and attempt payment - description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`, - metadata: { - type: 'final_overage_billing', - billingPeriod, - subscriptionId: stripeSubscriptionId, - cancelledAt: stripeSubscription.canceled_at?.toString() || '', + // Phase — Stripe final overage invoice. Idempotency keys ensure + // retry-safe creation; errors propagate up to the wrapper so the + // webhook gets retried rather than swallowed. + if (remainingOverage > 0 && stripeSubscriptionId) { + const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId) + const customerId = stripeSubscription.customer as string + const cents = Math.round(remainingOverage * 100) + const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000) + const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7) + + const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + + const overageInvoice = await stripe.invoices.create( + { + customer: customerId, + collection_method: 'charge_automatically', + auto_advance: true, + description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`, + metadata: { + type: 'final_overage_billing', + billingPeriod, + subscriptionId: stripeSubscriptionId, + cancelledAt: stripeSubscription.canceled_at?.toString() || '', + }, }, - }, - { idempotencyKey: invoiceIdemKey } - ) - - // Add the overage line item - await stripe.invoiceItems.create( - { - customer: customerId, - invoice: overageInvoice.id, - amount: cents, - currency: 'usd', - description: `Usage overage for ${subscription.plan} plan (Final billing period)`, - metadata: { - type: 'final_usage_overage', - usage: remainingOverage.toFixed(2), - totalOverage: totalOverage.toFixed(2), - billedOverage: billedOverage.toFixed(2), - billingPeriod, + { idempotencyKey: invoiceIdemKey } + ) + + await stripe.invoiceItems.create( + { + customer: customerId, + invoice: overageInvoice.id, + amount: cents, + currency: 'usd', + description: `Usage overage for ${subscription.plan} plan (Final billing period)`, + metadata: { + type: 'final_usage_overage', + usage: remainingOverage.toFixed(2), + totalOverage: totalOverage.toFixed(2), + billedOverage: billedOverage.toFixed(2), + billingPeriod, + }, }, - }, - { idempotencyKey: itemIdemKey } - ) - - // Finalize the invoice (this will trigger payment collection) - if (overageInvoice.id) { - await stripe.invoices.finalizeInvoice(overageInvoice.id) + { idempotencyKey: itemIdemKey } + ) + + if (overageInvoice.id) { + await stripe.invoices.finalizeInvoice(overageInvoice.id) + } + + logger.info('Created final overage invoice for cancelled subscription', { + subscriptionId: subscription.id, + stripeSubscriptionId, + invoiceId: overageInvoice.id, + totalOverage, + billedOverage, + remainingOverage, + cents, + billingPeriod, + }) + } else { + logger.info('No overage to bill for cancelled subscription', { + subscriptionId: subscription.id, + plan: subscription.plan, + }) } - logger.info('Created final overage invoice for cancelled subscription', { - subscriptionId: subscription.id, - stripeSubscriptionId, - invoiceId: overageInvoice.id, - totalOverage, - billedOverage, - remainingOverage, - cents, - billingPeriod, + // Phase — reset usage, then plan-specific cleanup. Both are + // idempotent on re-run (delete is already-no-op if org is gone; + // reset-by-delta is a no-op when trackers are already zeroed). + await resetUsageForSubscription({ + plan: subscription.plan, + referenceId: subscription.referenceId, }) - } catch (invoiceError) { - logger.error('Failed to create final overage invoice', { + + let restoredProCount = 0 + let organizationDeleted = false + let membersSynced = 0 + + if (await isSubscriptionOrgScoped(subscription)) { + const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) + restoredProCount = cleanup.restoredProCount + membersSynced = cleanup.membersSynced + organizationDeleted = cleanup.organizationDeleted + } else if (isPro(subscription.plan)) { + await syncUsageLimitsFromSubscription(subscription.referenceId) + membersSynced = 1 + } + + logger.info('Successfully processed subscription cancellation', { subscriptionId: subscription.id, stripeSubscriptionId, + plan: subscription.plan, totalOverage, - billedOverage, - remainingOverage, - error: invoiceError, + restoredProCount, + organizationDeleted, + membersSynced, }) - // Don't throw - we don't want to fail the webhook - } - } else { - logger.info('No overage to bill for cancelled subscription', { - subscriptionId: subscription.id, - plan: subscription.plan, - }) - } - - // Reset usage after billing - await resetUsageForSubscription({ - plan: subscription.plan, - referenceId: subscription.referenceId, - }) - // Plan-specific cleanup after billing - let restoredProCount = 0 - let organizationDeleted = false - let membersSynced = 0 - - if (await isSubscriptionOrgScoped(subscription)) { - const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) - restoredProCount = cleanup.restoredProCount - membersSynced = cleanup.membersSynced - organizationDeleted = cleanup.organizationDeleted - } else if (isPro(subscription.plan)) { - await syncUsageLimitsFromSubscription(subscription.referenceId) - membersSynced = 1 - } - - // Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler - // We handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup - - logger.info('Successfully processed subscription cancellation', { - subscriptionId: subscription.id, - stripeSubscriptionId, - plan: subscription.plan, - totalOverage, - restoredProCount, - organizationDeleted, - membersSynced, - }) + captureServerEvent(subscription.referenceId, 'subscription_cancelled', { + plan: subscription.plan ?? 'unknown', + reference_id: subscription.referenceId, + }) - captureServerEvent(subscription.referenceId, 'subscription_cancelled', { - plan: subscription.plan ?? 'unknown', - reference_id: subscription.referenceId, - }) + return { totalOverage, remainingOverage, restoredProCount, organizationDeleted } + } + ) } catch (error) { logger.error('Failed to handle subscription deletion', { subscriptionId: subscription.id, - stripeSubscriptionId: subscription.stripeSubscriptionId || '', + stripeSubscriptionId, error, }) - throw error // Re-throw to signal webhook failure for retry + throw error } } diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index 96ed86a3bb3..14e4cb46cee 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { idempotencyKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { eq, lt } from 'drizzle-orm' import { getRedisClient } from '@/lib/core/config/redis' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getStorageMethod, type StorageMethod } from '@/lib/core/storage' @@ -16,6 +16,20 @@ export interface IdempotencyConfig { namespace?: string /** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */ retryFailures?: boolean + /** + * Force a specific storage backend regardless of the environment's + * auto-detection. Use `'database'` for correctness-critical flows + * (money, billing, compliance) where the claim + operation should + * fate-share with the Postgres transaction — this closes the narrow + * window where the operation commits to DB but `storeResult` to Redis + * fails and the retry re-runs the operation. Latency cost is 1–5ms + * per call, imperceptible on webhook code paths. + * + * Leave unset (or set `'redis'`) for latency-sensitive, high-volume + * flows like app webhook triggers where the scale benefits of Redis + * outweigh the narrow durability window. + */ + forceStorage?: StorageMethod } export interface IdempotencyResult { @@ -50,11 +64,12 @@ const POLL_INTERVAL_MS = 1000 * that need duplicate prevention. * * Storage is determined once based on configuration: - * - If REDIS_URL is set → Redis - * - If REDIS_URL is not set → PostgreSQL + * - If `forceStorage` is set → that backend unconditionally + * - Else if `REDIS_URL` is set → Redis + * - Else → PostgreSQL */ export class IdempotencyService { - private config: Required + private config: Required> private storageMethod: StorageMethod constructor(config: IdempotencyConfig = {}) { @@ -63,9 +78,10 @@ export class IdempotencyService { namespace: config.namespace ?? 'default', retryFailures: config.retryFailures ?? false, } - this.storageMethod = getStorageMethod() + this.storageMethod = config.forceStorage ?? getStorageMethod() logger.info(`IdempotencyService using ${this.storageMethod} storage`, { namespace: this.config.namespace, + forced: Boolean(config.forceStorage), }) } @@ -220,15 +236,31 @@ export class IdempotencyService { normalizedKey: string, inProgressResult: ProcessingResult ): Promise { + const now = new Date() + const expiredBefore = new Date(now.getTime() - this.config.ttlSeconds * 1000) + + // `ON CONFLICT DO UPDATE WHERE created_at < expiredBefore` steals the + // claim when the existing row has outlived the TTL (e.g. a prior + // holder crashed mid-operation and never wrote `completed`/`failed` + // or released the key). RETURNING yields a row in two cases: + // (1) fresh INSERT — no prior row existed; + // (2) UPDATE of an expired row — WHERE matched. + // An empty RETURNING means conflict with an unexpired row; the + // existing holder is still live and we must not steal. const insertResult = await db .insert(idempotencyKey) .values({ key: normalizedKey, result: inProgressResult, - createdAt: new Date(), + createdAt: now, }) - .onConflictDoNothing({ + .onConflictDoUpdate({ target: [idempotencyKey.key], + set: { + result: inProgressResult, + createdAt: now, + }, + setWhere: lt(idempotencyKey.createdAt, expiredBefore), }) .returning({ key: idempotencyKey.key }) @@ -489,7 +521,18 @@ export const pollingIdempotency = new IdempotencyService({ retryFailures: true, }) +/** + * Used by the internal `/api/billing/update-cost` endpoint (copilot, + * workspace-chat, MCP, mothership) to dedupe cost-recording calls. Storage + * is forced to Postgres: the operation writes AI cost to `user_stats`, + * and if Redis evicts the dedup key under memory pressure (high call + * volume) or drops it on restart, a retry would double-record usage — + * real money. DB storage fate-shares with `user_stats` and is + * eviction-proof; ~1-5ms added latency is invisible against LLM call + * latency. + */ export const billingIdempotency = new IdempotencyService({ namespace: 'billing', ttlSeconds: 60 * 60, // 1 hour + forceStorage: 'database', }) diff --git a/apps/sim/lib/core/outbox/service.test.ts b/apps/sim/lib/core/outbox/service.test.ts new file mode 100644 index 00000000000..5b282e7d12d --- /dev/null +++ b/apps/sim/lib/core/outbox/service.test.ts @@ -0,0 +1,385 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type OutboxRow = { + id: string + eventType: string + payload: unknown + status: 'pending' | 'processing' | 'completed' | 'dead_letter' + attempts: number + maxAttempts: number + availableAt: Date + lockedAt: Date | null + lastError: string | null + createdAt: Date + processedAt: Date | null +} + +// Hoisted mock state — all tests manipulate these directly. +const { state, mockDb } = vi.hoisted(() => { + const state = { + // Rows returned from the FOR UPDATE SKIP LOCKED select in claimBatch. + claimedRows: [] as OutboxRow[], + // Whether the terminal update (lease CAS) should report a match. + leaseHeld: true, + // IDs the reaper's UPDATE should return (simulates stuck `processing` rows). + reapedRowIds: [] as string[], + // Everything written (for assertions). + inserts: [] as Array<{ values: unknown }>, + updates: [] as Array<{ set: Record; where?: unknown }>, + } + + const makeUpdateChain = () => { + const row: { set: Record; where?: unknown } = { set: {} } + const chain: Record = {} + chain.set = vi.fn((s: Record) => { + row.set = s + return chain + }) + chain.where = vi.fn((w: unknown) => { + row.where = w + state.updates.push(row) + return chain + }) + chain.returning = vi.fn(async () => { + // Terminal UPDATE (lease CAS): has `attempts` + `availableAt` + // on retry, or explicit completed/dead_letter. Reaper path sets + // status='pending' without attempts/availableAt. + const isReaperUpdate = + row.set.status === 'pending' && !('attempts' in row.set) && !('availableAt' in row.set) + + if (isReaperUpdate) { + return state.reapedRowIds.map((id) => ({ id })) + } + + if ( + row.set.status === 'completed' || + row.set.status === 'dead_letter' || + (row.set.status === 'pending' && 'attempts' in row.set && 'availableAt' in row.set) + ) { + return state.leaseHeld ? [{ id: 'evt-1' }] : [] + } + + return [] + }) + return chain + } + + const makeSelectChain = () => { + const chain: Record = {} + const self = () => chain + chain.from = vi.fn(self) + chain.where = vi.fn(self) + chain.orderBy = vi.fn(self) + chain.limit = vi.fn(self) + chain.for = vi.fn(async () => state.claimedRows) + return chain + } + + const mockDb = { + insert: vi.fn(() => { + const chain: Record = {} + chain.values = vi.fn(async (v: unknown) => { + state.inserts.push({ values: v }) + }) + return chain + }), + update: vi.fn(() => makeUpdateChain()), + select: vi.fn(() => makeSelectChain()), + transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn(mockDb)), + } + + return { state, mockDb } +}) + +vi.mock('@sim/db', () => ({ db: mockDb })) + +vi.mock('@sim/db/schema', () => ({ + outboxEvent: { + id: 'outbox_event.id', + eventType: 'outbox_event.event_type', + payload: 'outbox_event.payload', + status: 'outbox_event.status', + attempts: 'outbox_event.attempts', + maxAttempts: 'outbox_event.max_attempts', + availableAt: 'outbox_event.available_at', + lockedAt: 'outbox_event.locked_at', + lastError: 'outbox_event.last_error', + createdAt: 'outbox_event.created_at', + processedAt: 'outbox_event.processed_at', + $inferSelect: {} as OutboxRow, + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args) => ({ _op: 'and', args })), + asc: vi.fn((col) => ({ _op: 'asc', col })), + eq: vi.fn((col, val) => ({ _op: 'eq', col, val })), + inArray: vi.fn((col, vals) => ({ _op: 'inArray', col, vals })), + lte: vi.fn((col, val) => ({ _op: 'lte', col, val })), +})) + +vi.mock('@/lib/core/utils/uuid', () => ({ + generateId: vi.fn(() => 'test-event-id'), +})) + +import { enqueueOutboxEvent, processOutboxEvents } from './service' + +function makePendingRow(overrides: Partial = {}): OutboxRow { + return { + id: 'evt-1', + eventType: 'test.event', + payload: { foo: 'bar' }, + status: 'pending', + attempts: 0, + maxAttempts: 10, + availableAt: new Date(Date.now() - 1000), + lockedAt: null, + lastError: null, + createdAt: new Date(Date.now() - 5000), + processedAt: null, + ...overrides, + } +} + +function resetState() { + state.claimedRows = [] + state.leaseHeld = true + state.reapedRowIds = [] + state.inserts.length = 0 + state.updates.length = 0 +} + +describe('enqueueOutboxEvent', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('inserts a row with the given event type and payload', async () => { + const id = await enqueueOutboxEvent(mockDb, 'test.event', { foo: 'bar' }) + expect(id).toBe('test-event-id') + expect(state.inserts[0].values).toMatchObject({ + id: 'test-event-id', + eventType: 'test.event', + payload: { foo: 'bar' }, + maxAttempts: 10, + }) + }) + + it('respects maxAttempts override', async () => { + await enqueueOutboxEvent(mockDb, 'test.event', {}, { maxAttempts: 3 }) + expect(state.inserts[0].values).toMatchObject({ maxAttempts: 3 }) + }) + + it('respects availableAt override for delayed processing', async () => { + const future = new Date(Date.now() + 60_000) + await enqueueOutboxEvent(mockDb, 'test.event', {}, { availableAt: future }) + expect((state.inserts[0].values as { availableAt: Date }).availableAt).toBe(future) + }) +}) + +describe('processOutboxEvents — empty / no handler', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('returns zero counts when no events are due', async () => { + const result = await processOutboxEvents({}) + expect(result).toEqual({ + processed: 0, + retried: 0, + deadLettered: 0, + leaseLost: 0, + reaped: 0, + }) + }) + + it('dead-letters events with no registered handler', async () => { + state.claimedRows = [makePendingRow({ eventType: 'unknown.event' })] + + const result = await processOutboxEvents({}) + + expect(result.deadLettered).toBe(1) + const terminal = state.updates.find((u) => u.set.status === 'dead_letter') + expect(terminal).toBeDefined() + expect(terminal?.set.lastError).toMatch(/No handler registered/) + }) +}) + +describe('processOutboxEvents — handler success and retry', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('transitions to completed on handler success and passes context to handler', async () => { + const handlerCalls: Array<{ payload: unknown; eventId: string; attempts: number }> = [] + const handler = vi.fn(async (payload: unknown, ctx: { eventId: string; attempts: number }) => { + handlerCalls.push({ payload, eventId: ctx.eventId, attempts: ctx.attempts }) + }) + + state.claimedRows = [makePendingRow()] + + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.processed).toBe(1) + expect(handlerCalls).toEqual([{ payload: { foo: 'bar' }, eventId: 'evt-1', attempts: 0 }]) + const completeUpdate = state.updates.find((u) => u.set.status === 'completed') + expect(completeUpdate).toBeDefined() + }) + + it('schedules retry with exponential backoff on handler failure below maxAttempts', async () => { + const handler = vi.fn(async () => { + throw new Error('transient failure') + }) + + state.claimedRows = [makePendingRow({ attempts: 2 })] + + const before = Date.now() + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.retried).toBe(1) + const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + expect(retryUpdate).toBeDefined() + expect(retryUpdate?.set.attempts).toBe(3) + expect(retryUpdate?.set.lastError).toBe('transient failure') + // Backoff after nextAttempts=3: 1000 * 2^3 = 8000ms + const scheduledAt = retryUpdate?.set.availableAt as Date + expect(scheduledAt.getTime()).toBeGreaterThan(before + 7500) + expect(scheduledAt.getTime()).toBeLessThan(before + 10_000) + }) + + it('dead-letters on failure when attempts reaches maxAttempts', async () => { + const handler = vi.fn(async () => { + throw new Error('permanent failure') + }) + + state.claimedRows = [makePendingRow({ attempts: 9, maxAttempts: 10 })] + + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.deadLettered).toBe(1) + const deadUpdate = state.updates.find((u) => u.set.status === 'dead_letter') + expect(deadUpdate).toBeDefined() + expect(deadUpdate?.set.attempts).toBe(10) + expect(deadUpdate?.set.lastError).toBe('permanent failure') + }) + + it('caps exponential backoff at 1 hour', async () => { + const handler = vi.fn(async () => { + throw new Error('transient') + }) + + state.claimedRows = [makePendingRow({ attempts: 20, maxAttempts: 100 })] + + const before = Date.now() + await processOutboxEvents({ 'test.event': handler }) + + const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + expect(retryUpdate).toBeDefined() + const scheduledAt = retryUpdate?.set.availableAt as Date + // 1hr = 3,600,000ms + expect(scheduledAt.getTime()).toBeLessThan(before + 3_600_000 + 1000) + expect(scheduledAt.getTime()).toBeGreaterThan(before + 3_599_000) + }) +}) + +describe('processOutboxEvents — lease CAS / reaper race', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('reports leaseLost when completion UPDATE affects zero rows', async () => { + const handler = vi.fn(async () => { + // "succeeds" but terminal write will fail the lease CAS + }) + + state.claimedRows = [makePendingRow()] + state.leaseHeld = false + + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.leaseLost).toBe(1) + expect(result.processed).toBe(0) + }) + + it('reports leaseLost on retry-schedule UPDATE when row was reclaimed', async () => { + const handler = vi.fn(async () => { + throw new Error('transient') + }) + + state.claimedRows = [makePendingRow({ attempts: 2 })] + state.leaseHeld = false + + const result = await processOutboxEvents({ 'test.event': handler }) + + expect(result.leaseLost).toBe(1) + expect(result.retried).toBe(0) + }) +}) + +describe('processOutboxEvents — handler timeout', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('times out a stuck handler and schedules retry', async () => { + const neverResolves = vi.fn(() => new Promise(() => {})) + + state.claimedRows = [makePendingRow({ attempts: 0 })] + + const promise = processOutboxEvents({ 'test.event': neverResolves }) + // Must exceed DEFAULT_HANDLER_TIMEOUT_MS (90s). + await vi.advanceTimersByTimeAsync(90 * 1000 + 1) + const result = await promise + + expect(result.retried).toBe(1) + const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set) + expect(retryUpdate?.set.lastError).toMatch(/timed out/) + }) +}) + +describe('processOutboxEvents — reaper recovery', () => { + beforeEach(() => { + vi.clearAllMocks() + resetState() + }) + + it('reaps stuck processing rows back to pending and reports count', async () => { + state.reapedRowIds = ['stuck-1', 'stuck-2', 'stuck-3'] + + const result = await processOutboxEvents({}) + + expect(result.reaped).toBe(3) + expect(result.processed).toBe(0) + + // The reaper's UPDATE sets status='pending' with NO attempts / availableAt + // fields — that's how runHandler's retry update is distinguished from it. + const reaperUpdate = state.updates.find( + (u) => u.set.status === 'pending' && !('attempts' in u.set) && !('availableAt' in u.set) + ) + expect(reaperUpdate).toBeDefined() + expect(reaperUpdate?.set.lockedAt).toBeNull() + }) + + it('returns zero reaped when no rows are stuck', async () => { + const result = await processOutboxEvents({}) + expect(result.reaped).toBe(0) + }) +}) diff --git a/apps/sim/lib/core/outbox/service.ts b/apps/sim/lib/core/outbox/service.ts new file mode 100644 index 00000000000..e8b29570a30 --- /dev/null +++ b/apps/sim/lib/core/outbox/service.ts @@ -0,0 +1,366 @@ +import { db } from '@sim/db' +import { outboxEvent } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, asc, eq, inArray, lte } from 'drizzle-orm' +import { generateId } from '@/lib/core/utils/uuid' + +const logger = createLogger('OutboxService') + +const DEFAULT_MAX_ATTEMPTS = 10 +const STUCK_PROCESSING_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes +const MAX_BACKOFF_MS = 60 * 60 * 1000 // 1 hour +const BASE_BACKOFF_MS = 1000 // 1 second, doubled per attempt +// Kept below the serverless route `maxDuration` (120s) so our in-process +// timeout fires before the platform kills the invocation and leaves the +// row stranded in `processing` for the 10-minute reaper window. Also well +// under `STUCK_PROCESSING_THRESHOLD_MS` so the reaper cannot steal a row +// a worker is still actively processing. +const DEFAULT_HANDLER_TIMEOUT_MS = 90 * 1000 // 90 seconds + +/** + * Context passed to every outbox handler. Use `eventId` as the Stripe + * (or any external service) idempotency key so that handler retries + * collapse on the external side: a second execution of the same event + * lands on the same Stripe invoice id / charge id rather than creating + * a duplicate. The outbox lease CAS handles our DB side. + */ +export interface OutboxEventContext { + eventId: string + eventType: string + /** How many times this event has been attempted (zero on first run). */ + attempts: number +} + +/** + * A handler invoked by the outbox worker for events of a given type. + * Throwing bumps `attempts` and schedules a retry via exponential + * backoff; a successful return transitions the event to `completed`. + */ +export type OutboxHandler = (payload: T, context: OutboxEventContext) => Promise + +/** + * Map of `eventType` → handler. Register all handlers in one place + * and pass them to `processOutboxEvents`. + */ +export type OutboxHandlerRegistry = Record + +export interface EnqueueOptions { + /** Total attempts before the event moves to `dead_letter`. Default 10. */ + maxAttempts?: number + /** Earliest time a worker may pick up this event. Default now. */ + availableAt?: Date +} + +export interface ProcessOutboxResult { + processed: number + retried: number + deadLettered: number + leaseLost: number + reaped: number +} + +/** + * Transactional outbox for reliable "DB write + external system" flows. + * + * Callers enqueue an event *inside* a `db.transaction` alongside the + * primary write; the event row commits or rolls back with the business + * data. A polling worker (invoked via the cron endpoint) claims pending + * rows with `SELECT ... FOR UPDATE SKIP LOCKED`, marks them as + * `processing`, runs the registered handler outside the transaction, + * and transitions the event to `completed` / `pending` (retry) / + * `dead_letter` (max attempts exceeded). + * + * Two-phase claim-then-process keeps external API calls out of DB + * transactions. A reaper at the top of each run reclaims `processing` + * rows whose worker died mid-operation (stale `lockedAt`). + * + * Enqueue must be called with a `tx` from `db.transaction` so atomicity + * with the primary write is preserved. `db` itself is also accepted but + * then the caller must guarantee the enqueue and the primary write share + * a transaction some other way (or none at all). + */ +export async function enqueueOutboxEvent( + executor: Pick, + eventType: string, + payload: T, + options: EnqueueOptions = {} +): Promise { + const id = generateId() + await executor.insert(outboxEvent).values({ + id, + eventType, + payload: payload as never, + maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + availableAt: options.availableAt ?? new Date(), + }) + logger.info('Enqueued outbox event', { id, eventType }) + return id +} + +/** + * Process one batch of outbox events. Safe to call concurrently from + * multiple workers — `SELECT FOR UPDATE SKIP LOCKED` serializes claims. + */ +export async function processOutboxEvents( + handlers: OutboxHandlerRegistry, + options: { batchSize?: number } = {} +): Promise { + const batchSize = options.batchSize ?? 10 + + const reaped = await reapStuckProcessingRows() + + const claimed = await claimBatch(batchSize) + if (claimed.length === 0) { + return { processed: 0, retried: 0, deadLettered: 0, leaseLost: 0, reaped } + } + + let processed = 0 + let retried = 0 + let deadLettered = 0 + let leaseLost = 0 + + for (const event of claimed) { + const result = await runHandler(event, handlers) + if (result === 'completed') processed++ + else if (result === 'dead_letter') deadLettered++ + else if (result === 'lease_lost') leaseLost++ + else retried++ + } + + return { processed, retried, deadLettered, leaseLost, reaped } +} + +/** + * Reaper: move `processing` rows whose worker died (stale `lockedAt`) + * back to `pending` so another worker can pick them up. Without this, + * a SIGKILL between claim and result-write would permanently strand + * the row in `processing`. + */ +async function reapStuckProcessingRows(): Promise { + const stuckBefore = new Date(Date.now() - STUCK_PROCESSING_THRESHOLD_MS) + const result = await db + .update(outboxEvent) + .set({ status: 'pending', lockedAt: null }) + .where(and(eq(outboxEvent.status, 'processing'), lte(outboxEvent.lockedAt, stuckBefore))) + .returning({ id: outboxEvent.id }) + + if (result.length > 0) { + logger.warn('Reaped stuck outbox processing rows', { + count: result.length, + thresholdMs: STUCK_PROCESSING_THRESHOLD_MS, + }) + } + return result.length +} + +/** + * Phase 1: claim a batch of due pending events. + * + * `SELECT ... FOR UPDATE SKIP LOCKED` atomically picks rows that no + * other worker is currently looking at. We then flip those rows to + * `processing` inside the same tx so the claim survives the lock + * release — the status change becomes the out-of-band mutual exclusion. + */ +async function claimBatch(batchSize: number): Promise<(typeof outboxEvent.$inferSelect)[]> { + const now = new Date() + return db.transaction(async (tx) => { + const rows = await tx + .select() + .from(outboxEvent) + .where(and(eq(outboxEvent.status, 'pending'), lte(outboxEvent.availableAt, now))) + .orderBy(asc(outboxEvent.createdAt)) + .limit(batchSize) + .for('update', { skipLocked: true }) + + if (rows.length === 0) return [] + + await tx + .update(outboxEvent) + .set({ status: 'processing', lockedAt: now }) + .where( + inArray( + outboxEvent.id, + rows.map((r) => r.id) + ) + ) + + // Return rows with the claim state we just committed. `lockedAt` + // on this object is the authoritative lease timestamp used by the + // terminal-update lease CAS (see `runHandler`). + return rows.map((row) => ({ + ...row, + status: 'processing' as const, + lockedAt: now, + })) + }) +} + +/** + * Phase 2: invoke the handler for a claimed event, outside any DB + * transaction, then transition the row to its terminal or retry state. + * + * Every terminal UPDATE is guarded by a lease CAS (`WHERE status = + * 'processing' AND locked_at = event.lockedAt`). This defends against + * the "slow handler + reaper" race: if our handler takes longer than + * `STUCK_PROCESSING_THRESHOLD_MS`, the reaper will have reset the row + * to `pending` and another worker may have reclaimed it with a fresh + * `locked_at`. Our stale terminal write's WHERE clause won't match — + * rowCount is 0 — and we log+skip instead of clobbering the new lease. + */ +async function runHandler( + event: typeof outboxEvent.$inferSelect, + handlers: OutboxHandlerRegistry +): Promise<'completed' | 'pending' | 'dead_letter' | 'lease_lost'> { + const handler = handlers[event.eventType] + + if (!handler) { + logger.error('No handler registered for outbox event type', { + eventId: event.id, + eventType: event.eventType, + }) + await updateIfLeaseHeld(event, { + status: 'dead_letter', + lastError: `No handler registered for event type '${event.eventType}'`, + processedAt: new Date(), + lockedAt: null, + }) + return 'dead_letter' + } + + try { + await runHandlerWithTimeout(handler, event) + const updated = await updateIfLeaseHeld(event, { + status: 'completed', + processedAt: new Date(), + lockedAt: null, + }) + if (!updated) { + logger.warn('Outbox event completion skipped — lease lost (reaped + reclaimed)', { + eventId: event.id, + eventType: event.eventType, + }) + return 'lease_lost' + } + logger.info('Outbox event processed', { + eventId: event.id, + eventType: event.eventType, + attempts: event.attempts + 1, + }) + return 'completed' + } catch (error) { + const nextAttempts = event.attempts + 1 + const isDead = nextAttempts >= event.maxAttempts + const errMsg = error instanceof Error ? error.message : String(error) + + if (isDead) { + const updated = await updateIfLeaseHeld(event, { + attempts: nextAttempts, + status: 'dead_letter', + lastError: errMsg, + processedAt: new Date(), + lockedAt: null, + }) + if (!updated) { + logger.warn('Outbox event dead-letter skipped — lease lost', { + eventId: event.id, + eventType: event.eventType, + }) + return 'lease_lost' + } + logger.error('Outbox event dead-lettered after max attempts', { + eventId: event.id, + eventType: event.eventType, + attempts: nextAttempts, + error: errMsg, + }) + return 'dead_letter' + } + + // Exponential backoff, capped at MAX_BACKOFF_MS. + const backoffMs = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** nextAttempts) + const nextAvailableAt = new Date(Date.now() + backoffMs) + const updated = await updateIfLeaseHeld(event, { + attempts: nextAttempts, + status: 'pending', + lastError: errMsg, + availableAt: nextAvailableAt, + lockedAt: null, + }) + if (!updated) { + logger.warn('Outbox event retry-schedule skipped — lease lost', { + eventId: event.id, + eventType: event.eventType, + }) + return 'lease_lost' + } + logger.warn('Outbox event failed, scheduled retry', { + eventId: event.id, + eventType: event.eventType, + attempts: nextAttempts, + backoffMs, + nextAvailableAt: nextAvailableAt.toISOString(), + error: errMsg, + }) + return 'pending' + } +} + +function runHandlerWithTimeout( + handler: OutboxHandler, + event: typeof outboxEvent.$inferSelect, + timeoutMs: number = DEFAULT_HANDLER_TIMEOUT_MS +): Promise { + const context: OutboxEventContext = { + eventId: event.id, + eventType: event.eventType, + attempts: event.attempts, + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Outbox handler timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + handler(event.payload, context) + .then((value) => { + clearTimeout(timeout) + resolve(value) + }) + .catch((err) => { + clearTimeout(timeout) + reject(err) + }) + }) +} + +/** + * Conditional terminal update scoped to the lease acquired at claim + * time. Returns true if the UPDATE affected a row, false if the row's + * lease was revoked (reaped, reclaimed by another worker). Callers + * treat `false` as a "lease lost" signal and skip without retrying — + * the newer owner is responsible for the row now. + */ +async function updateIfLeaseHeld( + event: typeof outboxEvent.$inferSelect, + patch: { + status: 'completed' | 'pending' | 'dead_letter' + attempts?: number + lastError?: string | null + availableAt?: Date + lockedAt: Date | null + processedAt?: Date | null + } +): Promise { + const whereClauses = [eq(outboxEvent.id, event.id), eq(outboxEvent.status, 'processing')] + if (event.lockedAt) { + whereClauses.push(eq(outboxEvent.lockedAt, event.lockedAt)) + } + + const result = await db + .update(outboxEvent) + .set(patch) + .where(and(...whereClauses)) + .returning({ id: outboxEvent.id }) + + return result.length > 0 +} diff --git a/packages/db/migrations/0191_unusual_mongu.sql b/packages/db/migrations/0191_unusual_mongu.sql new file mode 100644 index 00000000000..22d1f29d83d --- /dev/null +++ b/packages/db/migrations/0191_unusual_mongu.sql @@ -0,0 +1,17 @@ +CREATE TABLE "outbox_event" ( + "id" text PRIMARY KEY NOT NULL, + "event_type" text NOT NULL, + "payload" json NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "max_attempts" integer DEFAULT 10 NOT NULL, + "available_at" timestamp DEFAULT now() NOT NULL, + "locked_at" timestamp, + "last_error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "processed_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "user_stats" ADD COLUMN "pro_period_cost_snapshot_at" timestamp;--> statement-breakpoint +CREATE INDEX "outbox_event_status_available_idx" ON "outbox_event" USING btree ("status","available_at");--> statement-breakpoint +CREATE INDEX "outbox_event_locked_at_idx" ON "outbox_event" USING btree ("locked_at"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0191_snapshot.json b/packages/db/migrations/meta/0191_snapshot.json new file mode 100644 index 00000000000..387f6e38fe8 --- /dev/null +++ b/packages/db/migrations/meta/0191_snapshot.json @@ -0,0 +1,14785 @@ +{ + "id": "28bc7319-8037-40ec-bc22-42d167a46852", + "prevId": "bb103638-c742-4300-a0fe-757734e707f1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_idx": { + "name": "chat_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_idx": { + "name": "doc_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_idx": { + "name": "doc_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_idx": { + "name": "form_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_idx": { + "name": "kc_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_idx": { + "name": "kc_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_access_token_idx": { + "name": "oauth_access_token_access_token_idx", + "columns": [ + { + "expression": "access_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_token_idx": { + "name": "oauth_access_token_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": ["access_token"] + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": ["refresh_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_application": { + "name": "oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_application_client_id_idx": { + "name": "oauth_application_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_user_client_idx": { + "name": "oauth_consent_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_org_name_unique": { + "name": "permission_group_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_org_auto_add_unique": { + "name": "permission_group_org_auto_add_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_user_id_unique": { + "name": "permission_group_member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_idx": { + "name": "webhook_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_idx": { + "name": "workflow_mcp_tool_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_idx": { + "name": "workflow_schedule_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_name_active_unique": { + "name": "workspace_files_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input" + ] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 1c0b60d974c..4086f44b90e 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1331,6 +1331,13 @@ "when": 1776114737326, "tag": "0190_shocking_karma", "breakpoints": true + }, + { + "idx": 191, + "version": "7", + "when": 1776502306122, + "tag": "0191_unusual_mongu", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index d0efe10e48c..3c9aba327ad 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -768,6 +768,7 @@ export const userStats = pgTable('user_stats', { billedOverageThisPeriod: decimal('billed_overage_this_period').notNull().default('0'), // Amount of overage already billed via threshold billing // Pro usage snapshot when joining a team (to prevent double-billing) proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team + proPeriodCostSnapshotAt: timestamp('pro_period_cost_snapshot_at'), // When the snapshot was captured (= join moment). Used to cap daily-refresh computation so post-join refresh isn't deducted from pre-join personal Pro usage (and vice-versa for the org's pooled refresh). // Pre-purchased credits (for Pro users only) creditBalance: decimal('credit_balance').notNull().default('0'), // Copilot usage tracking @@ -1971,6 +1972,30 @@ export const idempotencyKey = pgTable( }) ) +export const outboxEvent = pgTable( + 'outbox_event', + { + id: text('id').primaryKey(), + eventType: text('event_type').notNull(), + payload: json('payload').notNull(), + status: text('status').notNull().default('pending'), + attempts: integer('attempts').notNull().default(0), + maxAttempts: integer('max_attempts').notNull().default(10), + availableAt: timestamp('available_at').notNull().defaultNow(), + lockedAt: timestamp('locked_at'), + lastError: text('last_error'), + createdAt: timestamp('created_at').notNull().defaultNow(), + processedAt: timestamp('processed_at'), + }, + (table) => ({ + statusAvailableIdx: index('outbox_event_status_available_idx').on( + table.status, + table.availableAt + ), + lockedAtIdx: index('outbox_event_locked_at_idx').on(table.lockedAt), + }) +) + export const mcpServers = pgTable( 'mcp_servers', { From 02161e1564583243cdc5cab31986ac2dedfc1823 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 18 Apr 2026 02:14:55 -0700 Subject: [PATCH 07/11] address comments --- .../lib/billing/calculations/usage-monitor.ts | 3 +- apps/sim/lib/billing/core/billing.ts | 62 ++++++++++++++----- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index a7a082cba36..bc333fe7ede 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -250,7 +250,8 @@ export async function checkUsageStatus( const percentUsed = effectiveLimit > 0 ? Math.min((currentUsage / effectiveLimit) * 100, 100) : 100 - const isExceeded = effectiveLimit > 0 && currentUsage >= effectiveLimit + + const isExceeded = currentUsage >= effectiveLimit const isWarning = !isExceeded && percentUsed >= WARNING_THRESHOLD logger.info('Final usage statistics', { diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index cfd74e84154..1a4aab4e3d1 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -6,7 +6,7 @@ import { getHighestPrioritySubscription, type SubscriptionMetadata, } from '@/lib/billing/core/subscription' -import { getUserUsageData } from '@/lib/billing/core/usage' +import { getOrgUsageLimit, getUserUsageData } from '@/lib/billing/core/usage' import { getCreditBalance } from '@/lib/billing/credits/balance' import { computeDailyRefreshConsumed, @@ -361,18 +361,50 @@ export async function getSimplifiedBillingSummary( // for org-scoped subs, which would N-times-count. const pooled = await aggregateOrgMemberStats(organizationId) - const totalCurrentUsage = pooled.currentPeriodCost + const rawCurrentUsage = pooled.currentPeriodCost const totalCopilotCost = pooled.currentPeriodCopilotCost const totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost + // Deduct daily-refresh credits against this specific org's pool. + // `usageData` is derived from the caller's priority subscription + // and may not match the requested org (multi-org admins, personal + // priority sub, etc.), so it cannot be reused here. + let refreshDeduction = 0 + if (isPaid(plan) && subscription.periodStart) { + const planDollars = getPlanTierDollars(plan) + if (planDollars > 0) { + const userBounds = await getOrgMemberRefreshBounds( + organizationId, + subscription.periodStart + ) + refreshDeduction = await computeDailyRefreshConsumed({ + userIds: pooled.memberIds, + periodStart: subscription.periodStart, + periodEnd: subscription.periodEnd ?? null, + planDollars, + seats: subscription.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, + }) + } + } + const effectiveCurrentUsage = Math.max(0, rawCurrentUsage - refreshDeduction) + + const { limit: orgUsageLimit } = await getOrgUsageLimit( + organizationId, + plan, + subscription.seats ?? null + ) + const percentUsed = - usageData.limit > 0 ? Math.round((usageData.currentUsage / usageData.limit) * 100) : 0 + orgUsageLimit > 0 ? Math.round((effectiveCurrentUsage / orgUsageLimit) * 100) : 0 + const isExceeded = effectiveCurrentUsage >= orgUsageLimit + const isWarning = !isExceeded && percentUsed >= 80 // Calculate days remaining in billing period - const daysRemaining = usageData.billingPeriodEnd + const daysRemaining = subscription.periodEnd ? Math.max( 0, - Math.ceil((usageData.billingPeriodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + Math.ceil((subscription.periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) ) : 0 @@ -382,11 +414,11 @@ export async function getSimplifiedBillingSummary( return { type: 'organization', plan: subscription.plan, - currentUsage: totalCurrentUsage, - usageLimit: usageData.limit, + currentUsage: effectiveCurrentUsage, + usageLimit: orgUsageLimit, percentUsed, - isWarning: percentUsed >= 80 && percentUsed < 100, - isExceeded: usageData.currentUsage >= usageData.limit, + isWarning, + isExceeded, daysRemaining, creditBalance: orgCredits.balance, billingInterval: orgBillingInterval, @@ -405,13 +437,13 @@ export async function getSimplifiedBillingSummary( cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined, // Usage details usage: { - current: usageData.currentUsage, - limit: usageData.limit, + current: effectiveCurrentUsage, + limit: orgUsageLimit, percentUsed, - isWarning: percentUsed >= 80 && percentUsed < 100, - isExceeded: usageData.currentUsage >= usageData.limit, - billingPeriodStart: usageData.billingPeriodStart, - billingPeriodEnd: usageData.billingPeriodEnd, + isWarning, + isExceeded, + billingPeriodStart: subscription.periodStart ?? null, + billingPeriodEnd: subscription.periodEnd ?? null, lastPeriodCost: usageData.lastPeriodCost, lastPeriodCopilotCost: totalLastPeriodCopilotCost, daysRemaining, From d2049d261099072f636365648cc1345efd05c69d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 18 Apr 2026 02:30:55 -0700 Subject: [PATCH 08/11] address comment on check --- apps/sim/lib/billing/credits/purchase.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/billing/credits/purchase.ts b/apps/sim/lib/billing/credits/purchase.ts index 75ed3e402ee..1f068de6d04 100644 --- a/apps/sim/lib/billing/credits/purchase.ts +++ b/apps/sim/lib/billing/credits/purchase.ts @@ -25,9 +25,10 @@ export async function setUsageLimitForCredits( ): Promise { try { const { basePrice } = getPlanPricing(plan) - // `||` not `??` — 0 is never a valid seat count for a paid sub. + const seatCount = seats || 1 - const planBase = Number(basePrice) * seatCount + const planBase = + entityType === 'organization' ? Number(basePrice) * seatCount : Number(basePrice) const creditBalanceNum = Number(creditBalance) const newLimit = planBase + creditBalanceNum From 420e1dfa03f38c54ab91a9595cd626c9881d2caf Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 18 Apr 2026 02:56:17 -0700 Subject: [PATCH 09/11] simplify --- .../lib/billing/calculations/usage-monitor.ts | 207 ++++++------------ 1 file changed, 63 insertions(+), 144 deletions(-) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index bc333fe7ede..6724a051444 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -1,21 +1,18 @@ import { db } from '@sim/db' -import { member, organization, subscription, userStats } from '@sim/db/schema' +import { member, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { getHighestPrioritySubscription, type HighestPrioritySubscription, } from '@/lib/billing/core/plan' -import { getOrgUsageLimit, getUserUsageLimit } from '@/lib/billing/core/usage' +import { getUserUsageLimit } from '@/lib/billing/core/usage' import { computeDailyRefreshConsumed, getOrgMemberRefreshBounds, } from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' -import { - ENTITLED_SUBSCRIPTION_STATUSES, - isOrgScopedSubscription, -} from '@/lib/billing/subscriptions/utils' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { toError } from '@/lib/core/utils/helpers' @@ -39,6 +36,57 @@ interface UsageData { organizationId: string | null } +/** + * Sum `currentPeriodCost` across all members of an org, then subtract + * daily-refresh credits (with per-user window bounds for mid-cycle + * joiners). + */ +async function computePooledOrgUsage( + organizationId: string, + sub: { + plan: string | null + seats: number | null + periodStart: Date | null + periodEnd: Date | null + } +): Promise { + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + if (teamMembers.length === 0) return 0 + + const memberIds = teamMembers.map((tm) => tm.userId) + const memberStatsRows = await db + .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + let pooled = 0 + for (const stats of memberStatsRows) { + pooled += Number.parseFloat(stats.current?.toString() || stats.total.toString()) + } + + if (isPaid(sub.plan) && sub.periodStart) { + const planDollars = getPlanTierDollars(sub.plan) + if (planDollars > 0) { + const userBounds = await getOrgMemberRefreshBounds(organizationId, sub.periodStart) + const refresh = await computeDailyRefreshConsumed({ + userIds: memberIds, + periodStart: sub.periodStart, + periodEnd: sub.periodEnd ?? null, + planDollars, + seats: sub.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, + }) + pooled = Math.max(0, pooled - refresh) + } + } + + return pooled +} + /** * Checks a user's cost usage against their subscription plan limit * and returns usage information including whether they're approaching the limit @@ -75,47 +123,13 @@ export async function checkUsageStatus( logger.info('Using stored usage limit', { userId, limit }) const subIsOrgScoped = isOrgScopedSubscription(sub, userId) + const scope: 'user' | 'organization' = subIsOrgScoped ? 'organization' : 'user' + const organizationId: string | null = subIsOrgScoped && sub ? sub.referenceId : null let currentUsage = 0 - let effectiveLimit = limit - let scope: 'user' | 'organization' = subIsOrgScoped ? 'organization' : 'user' - let blockingOrgId: string | null = subIsOrgScoped && sub ? sub.referenceId : null if (subIsOrgScoped && sub) { - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) - - let pooled = 0 - if (teamMembers.length > 0) { - const memberIds = teamMembers.map((tm) => tm.userId) - const memberStatsRows = await db - .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - for (const stats of memberStatsRows) { - pooled += Number.parseFloat(stats.current?.toString() || stats.total.toString()) - } - - if (isPaid(sub.plan) && sub.periodStart) { - const planDollars = getPlanTierDollars(sub.plan) - if (planDollars > 0) { - const userBounds = await getOrgMemberRefreshBounds(sub.referenceId, sub.periodStart) - const refresh = await computeDailyRefreshConsumed({ - userIds: memberIds, - periodStart: sub.periodStart, - periodEnd: sub.periodEnd ?? null, - planDollars, - seats: sub.seats || 1, - userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, - }) - pooled = Math.max(0, pooled - refresh) - } - } - } - currentUsage = pooled + currentUsage = await computePooledOrgUsage(sub.referenceId, sub) } else { const statsRecords = await db .select() @@ -155,114 +169,19 @@ export async function checkUsageStatus( currentUsage = Math.max(0, rawUsage - refresh) } - // Defense-in-depth: enforce every entitled org cap the user belongs - // to, even when their priority sub is personal. If a secondary org - // pool is blocking, surface its numbers so the error message does - // not quote personal usage while enforcing an org cap. - try { - const memberships = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, userId)) - - for (const m of memberships) { - // Already handled above as the primary org. - if (subIsOrgScoped && sub && sub.referenceId === m.organizationId) continue - - // Refresh math below needs THIS org's plan/period/seats, not - // the caller's primary sub (which may be a personal Pro). - const [orgSub] = await db - .select({ - plan: subscription.plan, - seats: subscription.seats, - periodStart: subscription.periodStart, - periodEnd: subscription.periodEnd, - }) - .from(subscription) - .where( - and( - eq(subscription.referenceId, m.organizationId), - inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) - if (!orgSub) continue - - const [org] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, m.organizationId)) - .limit(1) - if (!org) continue - - // Use the same resolver as primary-path enforcement so the - // `basePrice × seats` floor is applied here too. Reading - // `organization.orgUsageLimit` raw would miss the floor when - // the column is null or has drifted below minimum. - const { limit: orgCap } = await getOrgUsageLimit(org.id, orgSub.plan, orgSub.seats) - - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, org.id)) - - let pooledUsage = 0 - if (teamMembers.length > 0) { - const memberIds = teamMembers.map((tm) => tm.userId) - const allMemberStats = await db - .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - for (const stats of allMemberStats) { - pooledUsage += Number.parseFloat(stats.current?.toString() || stats.total.toString()) - } - } - - if (isPaid(orgSub.plan) && orgSub.periodStart) { - const planDollars = getPlanTierDollars(orgSub.plan) - if (planDollars > 0) { - const memberIds = teamMembers.map((tm) => tm.userId) - const userBounds = await getOrgMemberRefreshBounds(org.id, orgSub.periodStart) - const orgRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: memberIds, - periodStart: orgSub.periodStart, - periodEnd: orgSub.periodEnd ?? null, - planDollars, - seats: orgSub.seats || 1, - userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, - }) - pooledUsage = Math.max(0, pooledUsage - orgRefreshDeduction) - } - } - - if (orgCap > 0 && pooledUsage >= orgCap) { - currentUsage = pooledUsage - effectiveLimit = orgCap - scope = 'organization' - blockingOrgId = org.id - break - } - } - } catch (error) { - logger.warn('Error checking organization usage limits', { error, userId }) - } - - const percentUsed = - effectiveLimit > 0 ? Math.min((currentUsage / effectiveLimit) * 100, 100) : 100 - - const isExceeded = currentUsage >= effectiveLimit + const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 100 + const isExceeded = currentUsage >= limit const isWarning = !isExceeded && percentUsed >= WARNING_THRESHOLD logger.info('Final usage statistics', { userId, currentUsage, - limit: effectiveLimit, + limit, percentUsed, isWarning, isExceeded, scope, - organizationId: blockingOrgId, + organizationId, }) return { @@ -270,9 +189,9 @@ export async function checkUsageStatus( isWarning, isExceeded, currentUsage, - limit: effectiveLimit, + limit, scope, - organizationId: blockingOrgId, + organizationId, } } catch (error) { logger.error('Error checking usage status', { From 1e0e5e4811d41a4a34785cac3a1462ef8ac38d39 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 18 Apr 2026 10:37:05 -0700 Subject: [PATCH 10/11] cleanup code --- .../app/api/organizations/[id]/seats/route.ts | 23 +- .../api/v1/admin/subscriptions/[id]/route.ts | 9 +- apps/sim/lib/billing/authorization.ts | 17 +- .../lib/billing/calculations/usage-monitor.ts | 40 +- apps/sim/lib/billing/core/billing.ts | 110 ++++-- apps/sim/lib/billing/core/organization.ts | 38 +- apps/sim/lib/billing/core/subscription.ts | 73 ++-- apps/sim/lib/billing/core/usage.ts | 80 ++-- apps/sim/lib/billing/credits/balance.ts | 15 +- apps/sim/lib/billing/credits/purchase.ts | 57 ++- apps/sim/lib/billing/organization.ts | 3 +- .../lib/billing/organizations/membership.ts | 13 +- apps/sim/lib/billing/stripe-payment-method.ts | 74 ++++ apps/sim/lib/billing/threshold-billing.ts | 93 ++--- .../sim/lib/billing/webhooks/invoices.test.ts | 30 ++ apps/sim/lib/billing/webhooks/invoices.ts | 364 +++++++++--------- .../lib/billing/webhooks/outbox-handlers.ts | 57 +-- apps/sim/lib/billing/webhooks/subscription.ts | 7 +- 18 files changed, 563 insertions(+), 540 deletions(-) create mode 100644 apps/sim/lib/billing/stripe-payment-method.ts diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index 6a2be6238c0..3ea2b3f06ed 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -13,6 +13,8 @@ import { hasUsableSubscriptionStatus, USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' +import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management' import { isBillingEnabled } from '@/lib/core/config/feature-flags' const logger = createLogger('OrganizationSeatsAPI') @@ -164,8 +166,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ userId: session.user.id, }) - // Update the subscription item quantity using Stripe's recommended approach - // This will automatically prorate the billing const updatedSubscription = await stripe.subscriptions.update( orgSubscription.stripeSubscriptionId, { @@ -176,19 +176,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ }, ], proration_behavior: 'always_invoice', - } + }, + { idempotencyKey: `seats-update:${orgSubscription.stripeSubscriptionId}:${newSeatCount}` } ) - // Update our local database to reflect the change - // Note: This will also be updated via webhook, but we update immediately for UX - await db - .update(subscription) - .set({ - seats: newSeatCount, - }) - .where(eq(subscription.id, orgSubscription.id)) + await syncSeatsFromStripeQuantity( + orgSubscription.id, + orgSubscription.seats, + updatedSubscription.items.data[0]?.quantity ?? newSeatCount + ) - // Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum) const { basePrice } = getPlanPricing(orgSubscription.plan) const newMinimumLimit = newSeatCount * basePrice @@ -200,7 +197,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const currentOrgLimit = orgData.length > 0 && orgData[0].orgUsageLimit - ? Number.parseFloat(orgData[0].orgUsageLimit) + ? toNumber(toDecimal(orgData[0].orgUsageLimit)) : 0 // Update if new minimum is higher than current limit diff --git a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts index fa465d7286d..58d977c7707 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts @@ -134,10 +134,11 @@ export const DELETE = withAdminAuthParams(async (request, context) // (overage bill, usage reset, Pro restore, org delete) via // `handleSubscriptionDeleted`, so no outbox needed here. const stripe = requireStripeClient() - await stripe.subscriptions.cancel(existing.stripeSubscriptionId, { - prorate: true, - invoice_now: true, - }) + await stripe.subscriptions.cancel( + existing.stripeSubscriptionId, + { prorate: true, invoice_now: true }, + { idempotencyKey: `admin-cancel:${existing.stripeSubscriptionId}` } + ) logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', { subscriptionId, diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index 67dbc0c66a6..ccabddaa0ec 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -1,8 +1,6 @@ -import { db } from '@sim/db' -import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { hasPaidSubscription } from '@/lib/billing' +import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' const logger = createLogger('BillingAuthorization') @@ -24,14 +22,10 @@ export async function authorizeSubscriptionReference( referenceId: string, action?: string ): Promise { - // `isOrgScopedSubscription` returns `false` when referenceId === userId, - // which is exactly the "personal subscription" case we want to allow - // without further checks. if (!isOrgScopedSubscription({ referenceId }, userId)) { return true } - // Only block duplicate subscriptions during upgrade/checkout, not cancel/restore/list if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) { logger.warn('Blocking checkout - active subscription already exists for organization', { userId, @@ -40,12 +34,5 @@ export async function authorizeSubscriptionReference( return false } - const members = await db - .select() - .from(schema.member) - .where(and(eq(schema.member.userId, userId), eq(schema.member.organizationId, referenceId))) - - const member = members[0] - - return member?.role === 'owner' || member?.role === 'admin' + return isOrganizationOwnerOrAdmin(userId, referenceId) } diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 6724a051444..c57fdcc7cd6 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -1,18 +1,19 @@ import { db } from '@sim/db' import { member, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { getHighestPrioritySubscription, type HighestPrioritySubscription, } from '@/lib/billing/core/plan' -import { getUserUsageLimit } from '@/lib/billing/core/usage' +import { getPooledOrgCurrentPeriodCost, getUserUsageLimit } from '@/lib/billing/core/usage' import { computeDailyRefreshConsumed, getOrgMemberRefreshBounds, } from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { toError } from '@/lib/core/utils/helpers' @@ -50,23 +51,10 @@ async function computePooledOrgUsage( periodEnd: Date | null } ): Promise { - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, organizationId)) - - if (teamMembers.length === 0) return 0 - - const memberIds = teamMembers.map((tm) => tm.userId) - const memberStatsRows = await db - .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - let pooled = 0 - for (const stats of memberStatsRows) { - pooled += Number.parseFloat(stats.current?.toString() || stats.total.toString()) - } + const { memberIds, currentPeriodCost } = await getPooledOrgCurrentPeriodCost(organizationId) + if (memberIds.length === 0) return 0 + + let pooled = currentPeriodCost if (isPaid(sub.plan) && sub.periodStart) { const planDollars = getPlanTierDollars(sub.plan) @@ -99,9 +87,7 @@ export async function checkUsageStatus( if (!isBillingEnabled) { const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) const currentUsage = - statsRecords.length > 0 - ? Number.parseFloat(statsRecords[0].currentPeriodCost?.toString()) - : 0 + statsRecords.length > 0 ? toNumber(toDecimal(statsRecords[0].currentPeriodCost)) : 0 return { percentUsed: Math.min((currentUsage / 1000) * 100, 100), @@ -150,9 +136,7 @@ export async function checkUsageStatus( } } - const rawUsage = Number.parseFloat( - statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString() - ) + const rawUsage = toNumber(toDecimal(statsRecords[0].currentPeriodCost)) let refresh = 0 if (sub && isPaid(sub.plan) && sub.periodStart) { @@ -296,16 +280,12 @@ export async function checkServerSideUsageLimits( blocked: userStats.billingBlocked, blockedReason: userStats.billingBlockedReason, current: userStats.currentPeriodCost, - total: userStats.totalCost, }) .from(userStats) .where(eq(userStats.userId, userId)) .limit(1) - const currentUsage = - stats.length > 0 - ? Number.parseFloat(stats[0].current?.toString() || stats[0].total.toString()) - : 0 + const currentUsage = stats.length > 0 ? toNumber(toDecimal(stats[0].current)) : 0 if (stats.length > 0 && stats[0].blocked) { const message = diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 1a4aab4e3d1..df9a31eb7c9 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -29,7 +29,13 @@ import { createLogger } from '@sim/logger' const logger = createLogger('Billing') /** - * Get organization subscription directly by organization ID + * Get the organization's subscription row when its status is one of + * `ENTITLED_SUBSCRIPTION_STATUSES` (includes `past_due`). Use this + * when making billing-side decisions (overage math, limit reads, + * webhooks) where `past_due` still counts as an active paid tenant. + * For product-access gating use `getOrganizationSubscriptionUsable` + * (from `core/subscription.ts`), which excludes `past_due`. + * Returns `null` when there is no entitled sub. */ export async function getOrganizationSubscription(organizationId: string) { try { @@ -83,6 +89,13 @@ export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Pro * query. Used by org-scoped summary and overage calculations so we don't * call `getUserUsageData` per-member — that helper now returns the entire * pool for org-scoped subs, which would N-times-count the usage. + * + * The `currentPeriodCost` sum here is semantically identical to + * `getPooledOrgCurrentPeriodCost` (same `LEFT JOIN` + `toDecimal` + * null handling); this helper bundles the copilot fields in the same + * round-trip. Never fall back to lifetime `totalCost` on nulls — the + * column is `NOT NULL DEFAULT '0'` and mixing scopes would break + * current-period billing math. */ async function aggregateOrgMemberStats(organizationId: string): Promise<{ memberIds: string[] @@ -123,6 +136,54 @@ async function aggregateOrgMemberStats(organizationId: string): Promise<{ } } +/** + * Compute an org's overage amount from already-fetched pool/departed + * inputs. Internally performs one daily-refresh DB read to subtract + * refresh credits; callers are expected to have already loaded the + * pooled `currentPeriodCost` and `departedMemberUsage` (threshold + * billing passes lock-held values; `calculateSubscriptionOverage` + * passes lockless values from `aggregateOrgMemberStats`). Both + * callers route through this to keep the overage math in one place. + */ +export async function computeOrgOverageAmount(params: { + plan: string | null + seats: number | null + periodStart: Date | null + periodEnd: Date | null + organizationId: string + pooledCurrentPeriodCost: number + departedMemberUsage: number + memberIds: string[] +}): Promise<{ + effectiveUsage: number + baseSubscriptionAmount: number + dailyRefreshDeduction: number + totalOverage: number +}> { + const totalUsage = params.pooledCurrentPeriodCost + params.departedMemberUsage + + let dailyRefreshDeduction = 0 + const planDollars = getPlanTierDollars(params.plan) + if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) { + const userBounds = await getOrgMemberRefreshBounds(params.organizationId, params.periodStart) + dailyRefreshDeduction = await computeDailyRefreshConsumed({ + userIds: params.memberIds, + periodStart: params.periodStart, + periodEnd: params.periodEnd ?? null, + planDollars, + seats: params.seats || 1, + userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, + }) + } + + const effectiveUsage = Math.max(0, totalUsage - dailyRefreshDeduction) + const { basePrice } = getPlanPricing(params.plan ?? '') + const baseSubscriptionAmount = (params.seats || 1) * basePrice + const totalOverage = Math.max(0, effectiveUsage - baseSubscriptionAmount) + + return { effectiveUsage, baseSubscriptionAmount, dailyRefreshDeduction, totalOverage } +} + /** * Calculate overage amount for a subscription * Shared logic between invoice.finalized and customer.subscription.deleted handlers @@ -150,7 +211,6 @@ export async function calculateSubscriptionOverage(sub: { if (isOrgScoped) { const pooled = await aggregateOrgMemberStats(sub.referenceId) - const totalTeamUsageDecimal = toDecimal(pooled.currentPeriodCost) const orgData = await db .select({ departedMemberUsage: organization.departedMemberUsage }) @@ -158,41 +218,31 @@ export async function calculateSubscriptionOverage(sub: { .where(eq(organization.id, sub.referenceId)) .limit(1) - const departedUsageDecimal = - orgData.length > 0 ? toDecimal(orgData[0].departedMemberUsage) : new Decimal(0) - - const totalUsageWithDepartedDecimal = totalTeamUsageDecimal.plus(departedUsageDecimal) + const departedMemberUsage = + orgData.length > 0 ? toNumber(toDecimal(orgData[0].departedMemberUsage)) : 0 - let dailyRefreshDeduction = 0 - const planDollars = getPlanTierDollars(sub.plan) - if (planDollars > 0 && sub.periodStart) { - const userBounds = await getOrgMemberRefreshBounds(sub.referenceId, sub.periodStart) - dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: pooled.memberIds, - periodStart: sub.periodStart, - periodEnd: sub.periodEnd ?? null, - planDollars, - seats: sub.seats || 1, - userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, - }) - } + const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({ + plan: sub.plan, + seats: sub.seats ?? null, + periodStart: sub.periodStart ?? null, + periodEnd: sub.periodEnd ?? null, + organizationId: sub.referenceId, + pooledCurrentPeriodCost: pooled.currentPeriodCost, + departedMemberUsage, + memberIds: pooled.memberIds, + }) - const effectiveUsageDecimal = Decimal.max( - 0, - totalUsageWithDepartedDecimal.minus(toDecimal(dailyRefreshDeduction)) - ) - const { basePrice } = getPlanPricing(sub.plan ?? '') - const baseSubscriptionAmount = (sub.seats || 1) * basePrice - totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(baseSubscriptionAmount)) + totalOverageDecimal = toDecimal(totalOverage) logger.info('Calculated org-scoped overage', { subscriptionId: sub.id, plan: sub.plan, - currentMemberUsage: toNumber(totalTeamUsageDecimal), - departedMemberUsage: toNumber(departedUsageDecimal), - totalUsage: toNumber(totalUsageWithDepartedDecimal), + currentMemberUsage: pooled.currentPeriodCost, + departedMemberUsage, + totalUsage: pooled.currentPeriodCost + departedMemberUsage, + effectiveUsage, baseSubscriptionAmount, - totalOverage: toNumber(totalOverageDecimal), + totalOverage, }) } else if (isPro(sub.plan)) { // Read user_stats directly (not via `getUserUsageData`). Priority diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index b442ac0ce61..f23f38a3d6e 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -1,47 +1,23 @@ import { db } from '@sim/db' -import { member, organization, subscription, user, userStats } from '@sim/db/schema' +import { member, organization, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' -import { getPlanPricing } from '@/lib/billing/core/billing' +import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing' import { computeDailyRefreshConsumed, getOrgMemberRefreshBounds, } from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers' import { - ENTITLED_SUBSCRIPTION_STATUSES, getEffectiveSeats, getFreeTierLimit, hasUsableSubscriptionStatus, } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' const logger = createLogger('OrganizationBilling') -/** - * Get organization subscription directly by organization ID - * This is for our new pattern where referenceId = organizationId - */ -async function getOrganizationSubscription(organizationId: string) { - try { - const orgSubs = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) - - return orgSubs.length > 0 ? orgSubs[0] : null - } catch (error) { - logger.error('Error getting organization subscription', { error, organizationId }) - return null - } -} - function roundCurrency(value: number): number { return Math.round(value * 100) / 100 } @@ -179,16 +155,14 @@ export async function getOrganizationBillingData( let totalUsageLimit: number if (isEnterprise(subscription.plan)) { - const configuredLimit = organizationData.orgUsageLimit - ? Number.parseFloat(organizationData.orgUsageLimit) - : 0 + const configuredLimit = toNumber(toDecimal(organizationData.orgUsageLimit)) minimumBillingAmount = configuredLimit totalUsageLimit = configuredLimit } else { minimumBillingAmount = licensedSeats * pricePerSeat const configuredLimit = organizationData.orgUsageLimit - ? Number.parseFloat(organizationData.orgUsageLimit) + ? toNumber(toDecimal(organizationData.orgUsageLimit)) : null totalUsageLimit = configuredLimit !== null diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 23114d30c8b..f33c52a83f5 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -89,6 +89,35 @@ export async function syncSubscriptionPlan( return true } +/** + * Get the organization's subscription row when its status is one of + * `USABLE_SUBSCRIPTION_STATUSES` (product access — stricter than + * `ENTITLED_SUBSCRIPTION_STATUSES` which also includes `past_due`). + * Use this for feature-gating ("can this org use the product right + * now"). Use `getOrganizationSubscription` (from `core/billing.ts`) + * when you need the billing-side entitlement row that includes + * past-due subscriptions. Returns `null` when there is no usable sub. + */ +export async function getOrganizationSubscriptionUsable(organizationId: string) { + try { + const [orgSub] = await db + .select() + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) + + return orgSub ?? null + } catch (error) { + logger.error('Error getting usable organization subscription', { error, organizationId }) + return null + } +} + /** * Check if a referenceId (user ID or org ID) has a paid subscription row. * Used for duplicate subscription prevention and transfer safety. @@ -226,16 +255,7 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise { return false } - const [orgSub] = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, memberRecord.organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) + const orgSub = await getOrganizationSubscriptionUsable(memberRecord.organizationId) const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub)) @@ -339,16 +350,7 @@ export async function isOrganizationOnTeamOrEnterprisePlan( return false } - const [orgSub] = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) + const orgSub = await getOrganizationSubscriptionUsable(organizationId) return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub)) } catch (error) { @@ -375,16 +377,7 @@ export async function isOrganizationOnEnterprisePlan(organizationId: string): Pr return false } - const [orgSub] = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) + const orgSub = await getOrganizationSubscriptionUsable(organizationId) return !!orgSub && checkEnterprisePlan(orgSub) } catch (error) { diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 2d1dde364d3..c560acc63a9 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { member, organization, settings, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq, inArray } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { getEmailSubject, renderCreditsExhaustedEmail, @@ -42,6 +42,40 @@ export interface OrgUsageLimitResult { minimum: number } +/** + * Sum `currentPeriodCost` across all members of an organization. + * The single source of truth for pooled-usage reads so every caller + * applies identical null-handling and query shape. Does NOT apply + * daily-refresh deduction — callers layer that on top themselves + * because refresh math needs the caller's `sub` context (plan, + * period, seats, per-user bounds). + * + * Uses `LEFT JOIN` so members whose `userStats` row is missing still + * appear (contributing 0), which keeps `memberIds` complete for + * downstream refresh / bounds computations. + */ +export async function getPooledOrgCurrentPeriodCost( + organizationId: string +): Promise<{ memberIds: string[]; currentPeriodCost: number }> { + const rows = await db + .select({ + userId: member.userId, + currentPeriodCost: userStats.currentPeriodCost, + }) + .from(member) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(eq(member.organizationId, organizationId)) + + let pooled = new Decimal(0) + const memberIds: string[] = [] + for (const row of rows) { + memberIds.push(row.userId) + pooled = pooled.plus(toDecimal(row.currentPeriodCost)) + } + + return { memberIds, currentPeriodCost: toNumber(pooled) } +} + /** * Calculates the effective usage limit for an organization-scoped plan. * Enterprise uses the configured orgUsageLimit directly; every other @@ -184,24 +218,9 @@ export async function getUserUsageData(userId: string): Promise { ) limit = orgLimit.limit - const teamMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, subscription.referenceId)) - orgMemberIds = teamMembers.map((m) => m.userId) - - if (orgMemberIds.length > 0) { - const rows = await db - .select({ current: userStats.currentPeriodCost }) - .from(userStats) - .where(inArray(userStats.userId, orgMemberIds)) - - let pooled = toDecimal(0) - for (const row of rows) { - pooled = pooled.plus(toDecimal(row.current)) - } - currentUsage = toNumber(pooled) - } + const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId) + orgMemberIds = pooled.memberIds + currentUsage = pooled.currentPeriodCost } else { limit = stats.currentUsageLimit ? toNumber(toDecimal(stats.currentUsageLimit)) @@ -620,25 +639,10 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise m.userId) - refreshUserIds = memberIds - const rows = await db - .select({ current: userStats.currentPeriodCost }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - let pooled = new Decimal(0) - for (const r of rows) { - pooled = pooled.plus(toDecimal(r.current)) - } - rawCost = toNumber(pooled) + const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId) + if (pooled.memberIds.length === 0) return 0 + refreshUserIds = pooled.memberIds + rawCost = pooled.currentPeriodCost } else { const rows = await db .select({ current: userStats.currentPeriodCost }) diff --git a/apps/sim/lib/billing/credits/balance.ts b/apps/sim/lib/billing/credits/balance.ts index 66be2950248..69a23cea982 100644 --- a/apps/sim/lib/billing/credits/balance.ts +++ b/apps/sim/lib/billing/credits/balance.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { member, organization, userStats } from '@sim/db/schema' +import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPro, isTeam } from '@/lib/billing/plan-helpers' @@ -210,14 +210,3 @@ export async function canPurchaseCredits(userId: string): Promise { // Enterprise users must contact support to purchase credits return isPro(subscription.plan) || isTeam(subscription.plan) } - -export async function isOrgAdmin(userId: string, organizationId: string): Promise { - const memberRows = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) - .limit(1) - - if (memberRows.length === 0) return false - return memberRows[0].role === 'owner' || memberRows[0].role === 'admin' -} diff --git a/apps/sim/lib/billing/credits/purchase.ts b/apps/sim/lib/billing/credits/purchase.ts index 1f068de6d04..9d1cf7167c9 100644 --- a/apps/sim/lib/billing/credits/purchase.ts +++ b/apps/sim/lib/billing/credits/purchase.ts @@ -2,13 +2,15 @@ import { db } from '@sim/db' import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import type Stripe from 'stripe' import { getPlanPricing } from '@/lib/billing/core/billing' +import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { canPurchaseCredits, isOrgAdmin } from '@/lib/billing/credits/balance' +import { canPurchaseCredits } from '@/lib/billing/credits/balance' import { isEnterprise } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { getCustomerId, resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' const logger = createLogger('CreditPurchase') @@ -39,8 +41,7 @@ export async function setUsageLimitForCredits( .where(eq(organization.id, entityId)) .limit(1) - const currentLimit = - orgRows.length > 0 ? Number.parseFloat(orgRows[0].orgUsageLimit || '0') : 0 + const currentLimit = orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].orgUsageLimit)) : 0 if (newLimit > currentLimit) { await db @@ -66,7 +67,7 @@ export async function setUsageLimitForCredits( .limit(1) const currentLimit = - userStatsRows.length > 0 ? Number.parseFloat(userStatsRows[0].currentUsageLimit || '0') : 0 + userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentUsageLimit)) : 0 if (newLimit > currentLimit) { await db @@ -100,12 +101,6 @@ export interface PurchaseResult { error?: string } -function getPaymentMethodId( - pm: string | Stripe.PaymentMethod | null | undefined -): string | undefined { - return typeof pm === 'string' ? pm : pm?.id -} - export async function purchaseCredits(params: PurchaseCreditsParams): Promise { const { userId, amountDollars, requestId } = params @@ -134,7 +129,7 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise 0 && orgData[0].orgUsageLimit - ? Number.parseFloat(orgData[0].orgUsageLimit) + ? toNumber(toDecimal(orgData[0].orgUsageLimit)) : 0 // Update if no limit set, or if new seat-based minimum is higher diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index e063ea47b63..35b08984a32 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -18,6 +18,7 @@ import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' @@ -177,12 +178,10 @@ export async function restoreUserProSubscription(userId: string): Promise 0) { - const currentNum = Number.parseFloat(currentUsage) const restoredUsage = (currentNum + snapshotNum).toString() await db @@ -198,8 +197,8 @@ export async function restoreUserProSubscription(userId: string): Promise 0) { await db .update(organization) diff --git a/apps/sim/lib/billing/stripe-payment-method.ts b/apps/sim/lib/billing/stripe-payment-method.ts new file mode 100644 index 00000000000..1bb130ec913 --- /dev/null +++ b/apps/sim/lib/billing/stripe-payment-method.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import type Stripe from 'stripe' + +const logger = createLogger('StripePaymentMethod') + +/** + * Extract the payment-method id from any of the shapes Stripe returns + * for a `default_payment_method` field (id string, full object, null, + * or undefined). + */ +function getPaymentMethodId( + pm: string | Stripe.PaymentMethod | null | undefined +): string | undefined { + return typeof pm === 'string' ? pm : pm?.id +} + +/** + * Extract the customer id from any of the shapes Stripe returns for a + * `customer` field (id string, full `Customer`, or `DeletedCustomer`). + */ +export function getCustomerId( + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined +): string | undefined { + if (!customer) return undefined + return typeof customer === 'string' ? customer : customer.id +} + +/** + * Resolve a subscription's default payment method with fallback to the + * customer's invoice-settings PM. Used for ad-hoc invoices that are + * not directly linked to the subscription (overage, credits, threshold + * billing) so Stripe can auto-collect on finalize. + * + * Returns both the resolved PM id and the subscription's collection + * method so callers can pass it through to `invoices.create` without a + * second subscription retrieve. On any Stripe error the returned + * `collectionMethod` is `null` — callers should treat that as + * "unknown" and handle accordingly rather than assuming a default. + */ +export async function resolveDefaultPaymentMethod( + stripe: Stripe, + stripeSubscriptionId: string, + customerId: string +): Promise<{ + paymentMethodId: string | undefined + collectionMethod: 'charge_automatically' | 'send_invoice' | null +}> { + let collectionMethod: 'charge_automatically' | 'send_invoice' | null = null + let paymentMethodId: string | undefined + + try { + const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId) + collectionMethod = + sub.collection_method === 'send_invoice' ? 'send_invoice' : 'charge_automatically' + paymentMethodId = getPaymentMethodId(sub.default_payment_method) + + if (!paymentMethodId && collectionMethod === 'charge_automatically') { + const customer = await stripe.customers.retrieve(customerId) + if (customer && !('deleted' in customer)) { + paymentMethodId = getPaymentMethodId( + (customer as Stripe.Customer).invoice_settings?.default_payment_method + ) + } + } + } catch (error) { + logger.warn('Failed to resolve default payment method', { + stripeSubscriptionId, + customerId, + error: error instanceof Error ? error.message : error, + }) + } + + return { paymentMethodId, collectionMethod } +} diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 921b54821b6..254ebdc88bf 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -1,21 +1,20 @@ import { db } from '@sim/db' import { member, organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { eq, inArray, sql } from 'drizzle-orm' import { DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants' import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access' -import { calculateSubscriptionOverage, getPlanPricing } from '@/lib/billing/core/billing' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { calculateSubscriptionOverage, computeOrgOverageAmount } from '@/lib/billing/core/billing' import { - computeDailyRefreshConsumed, - getOrgMemberRefreshBounds, -} from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers' + getHighestPrioritySubscription, + getOrganizationSubscriptionUsable, +} from '@/lib/billing/core/subscription' +import { isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionAccess, isOrgScopedSubscription, - USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { env } from '@/lib/core/config/env' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' @@ -24,11 +23,6 @@ const logger = createLogger('ThresholdBilling') const OVERAGE_THRESHOLD = env.OVERAGE_THRESHOLD_DOLLARS || DEFAULT_OVERAGE_THRESHOLD -function parseDecimal(value: string | number | null | undefined): number { - if (value === null || value === undefined) return 0 - return Number.parseFloat(value.toString()) -} - export async function checkAndBillOverageThreshold(userId: string): Promise { try { const threshold = OVERAGE_THRESHOLD @@ -82,7 +76,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise 0) { creditsApplied = Math.min(creditBalance, amountToBill) @@ -220,23 +214,12 @@ export async function checkAndBillOrganizationOverageThreshold( logger.debug('Starting organization threshold billing check', { organizationId, threshold }) - const orgSubscriptions = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) - - if (orgSubscriptions.length === 0) { + const orgSubscription = await getOrganizationSubscriptionUsable(organizationId) + + if (!orgSubscription) { logger.debug('No active subscription for organization', { organizationId }) return } - - const orgSubscription = orgSubscriptions[0] logger.debug('Found organization subscription', { organizationId, plan: orgSubscription.plan, @@ -305,9 +288,9 @@ export async function checkAndBillOrganizationOverageThreshold( return } - let totalTeamUsage = parseDecimal(ownerStatsLock[0].currentPeriodCost) - const totalBilledOverage = parseDecimal(ownerStatsLock[0].billedOverageThisPeriod) - const orgCreditBalance = Number.parseFloat(orgLock[0].creditBalance?.toString() || '0') + let pooledCurrentPeriodCost = toNumber(toDecimal(ownerStatsLock[0].currentPeriodCost)) + const totalBilledOverage = toNumber(toDecimal(ownerStatsLock[0].billedOverageThisPeriod)) + const orgCreditBalance = toNumber(toDecimal(orgLock[0].creditBalance)) const nonOwnerIds = members.filter((m) => m.userId !== owner.userId).map((m) => m.userId) @@ -321,41 +304,33 @@ export async function checkAndBillOrganizationOverageThreshold( .where(inArray(userStats.userId, nonOwnerIds)) for (const stats of memberStatsRows) { - totalTeamUsage += parseDecimal(stats.currentPeriodCost) + pooledCurrentPeriodCost += toNumber(toDecimal(stats.currentPeriodCost)) } } - totalTeamUsage += parseDecimal(orgLock[0].departedMemberUsage) - - let dailyRefreshDeduction = 0 - if (isPaid(orgSubscription.plan) && orgSubscription.periodStart) { - const planDollars = getPlanTierDollars(orgSubscription.plan) - if (planDollars > 0) { - const allMemberIds = members.map((m) => m.userId) - const userBounds = await getOrgMemberRefreshBounds( - organizationId, - orgSubscription.periodStart - ) - dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: allMemberIds, - periodStart: orgSubscription.periodStart, - periodEnd: orgSubscription.periodEnd ?? null, - planDollars, - seats: orgSubscription.seats || 1, - userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, - }) - } - } + const departedMemberUsage = toNumber(toDecimal(orgLock[0].departedMemberUsage)) + + const { + totalOverage: currentOverage, + baseSubscriptionAmount: basePrice, + effectiveUsage: effectiveTeamUsage, + } = await computeOrgOverageAmount({ + plan: orgSubscription.plan, + seats: orgSubscription.seats ?? null, + periodStart: orgSubscription.periodStart ?? null, + periodEnd: orgSubscription.periodEnd ?? null, + organizationId, + pooledCurrentPeriodCost, + departedMemberUsage, + memberIds: members.map((m) => m.userId), + }) - const effectiveTeamUsage = Math.max(0, totalTeamUsage - dailyRefreshDeduction) - const { basePrice: basePricePerSeat } = getPlanPricing(orgSubscription.plan) - const basePrice = basePricePerSeat * (orgSubscription.seats || 1) - const currentOverage = Math.max(0, effectiveTeamUsage - basePrice) const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage) logger.debug('Organization threshold billing check', { organizationId, - totalTeamUsage, + totalTeamUsage: pooledCurrentPeriodCost + departedMemberUsage, + effectiveTeamUsage, basePrice, currentOverage, totalBilledOverage, diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index 670ba91415c..f0a004059b2 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -120,6 +120,36 @@ vi.mock('@/lib/billing/stripe-client', () => ({ requireStripeClient: vi.fn(), })) +vi.mock('@/lib/billing/stripe-payment-method', () => ({ + resolveDefaultPaymentMethod: vi.fn(async () => ({ + paymentMethodId: undefined, + collectionMethod: 'charge_automatically', + })), + getPaymentMethodId: vi.fn(), + getCustomerId: vi.fn(), +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing', 'past_due'], +})) + +vi.mock('@/lib/billing/utils/decimal', () => ({ + toDecimal: vi.fn((v: string | number | null | undefined) => { + if (v === null || v === undefined || v === '') return { toNumber: () => 0 } + return { toNumber: () => Number(v) } + }), + toNumber: vi.fn((d: { toNumber: () => number }) => d.toNumber()), +})) + +vi.mock('@/lib/billing/webhooks/idempotency', () => ({ + stripeWebhookIdempotency: { + executeWithIdempotency: vi.fn( + async (_provider: string, _identifier: string, operation: () => Promise) => + operation() + ), + }, +})) + vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: vi.fn(() => 'https://sim.test'), })) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 1975a460a8c..bed1a7834e4 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -17,6 +17,9 @@ import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership' import { isEnterprise } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -31,11 +34,6 @@ const METADATA_SUBSCRIPTION_INVOICE_TYPES = new Set([ 'overage_threshold_billing_org', ]) -function parseDecimal(value: string | number | null | undefined): number { - if (value === null || value === undefined) return 0 - return Number.parseFloat(value.toString()) -} - type InvoiceSubscriptionResolutionSource = | 'parent.subscription_details.subscription' | 'metadata.subscriptionId' @@ -374,7 +372,7 @@ export async function getBilledOverageForSubscription(sub: { .where(eq(userStats.userId, ownerId)) .limit(1) - return ownerStats.length > 0 ? parseDecimal(ownerStats[0].billedOverageThisPeriod) : 0 + return ownerStats.length > 0 ? toNumber(toDecimal(ownerStats[0].billedOverageThisPeriod)) : 0 } const userStatsRecords = await db @@ -383,7 +381,9 @@ export async function getBilledOverageForSubscription(sub: { .where(eq(userStats.userId, sub.referenceId)) .limit(1) - return userStatsRecords.length > 0 ? parseDecimal(userStatsRecords[0].billedOverageThisPeriod) : 0 + return userStatsRecords.length > 0 + ? toNumber(toDecimal(userStatsRecords[0].billedOverageThisPeriod)) + : 0 } export async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) { @@ -434,7 +434,7 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe .limit(1) if (currentStats.length > 0) { const current = currentStats[0].current || '0' - const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0') + const snapshot = toNumber(toDecimal(currentStats[0].snapshot)) const currentCopilot = currentStats[0].currentCopilot || '0' // Snapshot > 0: user joined a paid org mid-cycle. The pre-join @@ -453,7 +453,7 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe }) .where(eq(userStats.userId, sub.referenceId)) } else { - const totalLastPeriod = (Number.parseFloat(current) + snapshot).toString() + const totalLastPeriod = toNumber(toDecimal(current).plus(snapshot)).toString() // Delta-reset for the same reason as the org branch above. await db .update(userStats) @@ -521,7 +521,12 @@ async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise 0) { @@ -622,78 +627,80 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - // Handle credit purchase invoices if (invoice.metadata?.type === 'credit_purchase') { await handleCreditPurchaseSuccess(invoice) return } - const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_succeeded') - if (!resolvedInvoice) { - return - } + await stripeWebhookIdempotency.executeWithIdempotency( + 'invoice-payment-succeeded', + event.id, + async () => { + const resolvedInvoice = await resolveInvoiceSubscription( + invoice, + 'invoice.payment_succeeded' + ) + if (!resolvedInvoice) { + return + } - const { sub } = resolvedInvoice - const subIsOrgScoped = await isSubscriptionOrgScoped(sub) + const { sub } = resolvedInvoice + const subIsOrgScoped = await isSubscriptionOrgScoped(sub) - // Only reset usage here if the tenant was previously blocked; otherwise invoice.created already reset it - let wasBlocked = false - if (subIsOrgScoped) { - const membersRows = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) - const memberIds = membersRows.map((m) => m.userId) - if (memberIds.length > 0) { - const blockedRows = await db - .select({ blocked: userStats.billingBlocked }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - wasBlocked = blockedRows.some((row) => !!row.blocked) - } - } else { - const row = await db - .select({ blocked: userStats.billingBlocked }) - .from(userStats) - .where(eq(userStats.userId, sub.referenceId)) - .limit(1) - wasBlocked = row.length > 0 ? !!row[0].blocked : false - } + let wasBlocked = false + if (subIsOrgScoped) { + const membersRows = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + const memberIds = membersRows.map((m) => m.userId) + if (memberIds.length > 0) { + const blockedRows = await db + .select({ blocked: userStats.billingBlocked }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + wasBlocked = blockedRows.some((row) => !!row.blocked) + } + } else { + const row = await db + .select({ blocked: userStats.billingBlocked }) + .from(userStats) + .where(eq(userStats.userId, sub.referenceId)) + .limit(1) + wasBlocked = row.length > 0 ? !!row[0].blocked : false + } - // For proration invoices (mid-cycle upgrades/seat changes), only unblock if real money - // was collected. A $0 credit invoice from a downgrade should not unblock a user who - // was blocked for a different failed payment. - const isProrationInvoice = invoice.billing_reason === 'subscription_update' - const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0 + const isProrationInvoice = invoice.billing_reason === 'subscription_update' + const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0 - if (shouldUnblock) { - if (subIsOrgScoped) { - await unblockOrgMembers(sub.referenceId, 'payment_failed') - } else { - await db - .update(userStats) - .set({ billingBlocked: false, billingBlockedReason: null }) - .where( - and( - eq(userStats.userId, sub.referenceId), - eq(userStats.billingBlockedReason, 'payment_failed') - ) - ) - } - } else { - logger.info('Skipping unblock for zero-amount proration invoice', { - invoiceId: invoice.id, - billingReason: invoice.billing_reason, - amountPaid: invoice.amount_paid, - }) - } + if (shouldUnblock) { + if (subIsOrgScoped) { + await unblockOrgMembers(sub.referenceId, 'payment_failed') + } else { + await db + .update(userStats) + .set({ billingBlocked: false, billingBlockedReason: null }) + .where( + and( + eq(userStats.userId, sub.referenceId), + eq(userStats.billingBlockedReason, 'payment_failed') + ) + ) + } + } else { + logger.info('Skipping unblock for zero-amount proration invoice', { + invoiceId: invoice.id, + billingReason: invoice.billing_reason, + amountPaid: invoice.amount_paid, + }) + } - // Only reset usage for cycle renewals — proration invoices should not wipe - // accumulated usage mid-cycle. - if (wasBlocked && !isProrationInvoice) { - await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) - } + if (wasBlocked && !isProrationInvoice) { + await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + } + } + ) } catch (error) { logger.error('Failed to handle invoice payment succeeded', { eventId: event.id, error }) throw error @@ -708,96 +715,100 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_failed') - if (!resolvedInvoice) { - return - } - - const { invoiceType, resolutionSource, stripeSubscriptionId, sub } = resolvedInvoice - - // Extract and validate customer ID - const customerId = invoice.customer - if (!customerId || typeof customerId !== 'string') { - logger.error('Invalid customer ID on invoice', { - invoiceId: invoice.id, - customer: invoice.customer, - }) - return - } + await stripeWebhookIdempotency.executeWithIdempotency( + 'invoice-payment-failed', + event.id, + async () => { + const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_failed') + if (!resolvedInvoice) { + return + } - const failedAmount = invoice.amount_due / 100 // Convert from cents to dollars - const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' - const attemptCount = invoice.attempt_count ?? 1 + const { invoiceType, resolutionSource, stripeSubscriptionId, sub } = resolvedInvoice - logger.warn('Invoice payment failed', { - invoiceId: invoice.id, - customerId, - failedAmount, - billingPeriod, - attemptCount, - customerEmail: invoice.customer_email, - hostedInvoiceUrl: invoice.hosted_invoice_url, - invoiceType: invoiceType ?? 'subscription', - resolutionSource, - }) + const customerId = invoice.customer + if (!customerId || typeof customerId !== 'string') { + logger.error('Invalid customer ID on invoice', { + invoiceId: invoice.id, + customer: invoice.customer, + }) + return + } - // Block users after first payment failure - if (attemptCount >= 1) { - logger.error('Payment failure - blocking users', { - customerId, - attemptCount, - invoiceId: invoice.id, - invoiceType: invoiceType ?? 'subscription', - resolutionSource, - stripeSubscriptionId, - }) + const failedAmount = invoice.amount_due / 100 + const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' + const attemptCount = invoice.attempt_count ?? 1 - if (await isSubscriptionOrgScoped(sub)) { - const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') - logger.info('Blocked org members due to payment failure', { + logger.warn('Invoice payment failed', { + invoiceId: invoice.id, + customerId, + failedAmount, + billingPeriod, + attemptCount, + customerEmail: invoice.customer_email, + hostedInvoiceUrl: invoice.hosted_invoice_url, invoiceType: invoiceType ?? 'subscription', - memberCount, - organizationId: sub.referenceId, + resolutionSource, }) - } else { - await db - .update(userStats) - .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) - .where( - and( - eq(userStats.userId, sub.referenceId), - or( - ne(userStats.billingBlockedReason, 'dispute'), - isNull(userStats.billingBlockedReason) + + if (attemptCount >= 1) { + logger.error('Payment failure - blocking users', { + customerId, + attemptCount, + invoiceId: invoice.id, + invoiceType: invoiceType ?? 'subscription', + resolutionSource, + stripeSubscriptionId, + }) + + if (await isSubscriptionOrgScoped(sub)) { + const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') + logger.info('Blocked org members due to payment failure', { + invoiceType: invoiceType ?? 'subscription', + memberCount, + organizationId: sub.referenceId, + }) + } else { + await db + .update(userStats) + .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) + .where( + and( + eq(userStats.userId, sub.referenceId), + or( + ne(userStats.billingBlockedReason, 'dispute'), + isNull(userStats.billingBlockedReason) + ) + ) ) - ) - ) - logger.info('Blocked user due to payment failure', { - invoiceType: invoiceType ?? 'subscription', - userId: sub.referenceId, - }) - } + logger.info('Blocked user due to payment failure', { + invoiceType: invoiceType ?? 'subscription', + userId: sub.referenceId, + }) + } - if (attemptCount === 1) { - await sendPaymentFailureEmails(sub, invoice, customerId) - logger.info('Payment failure email sent on first attempt', { - customerId, - invoiceId: invoice.id, - }) - } else { - logger.info('Skipping payment failure email on retry attempt', { - attemptCount, - customerId, - invoiceId: invoice.id, - }) + if (attemptCount === 1) { + await sendPaymentFailureEmails(sub, invoice, customerId) + logger.info('Payment failure email sent on first attempt', { + customerId, + invoiceId: invoice.id, + }) + } else { + logger.info('Skipping payment failure email on retry attempt', { + attemptCount, + customerId, + invoiceId: invoice.id, + }) + } + } } - } + ) } catch (error) { logger.error('Failed to handle invoice payment failed', { eventId: event.id, error, }) - throw error // Re-throw to signal webhook failure + throw error } } @@ -885,8 +896,7 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { .for('update') .limit(1) - const billedInTx = - trackerRows.length > 0 ? Number.parseFloat(trackerRows[0].billed?.toString() || '0') : 0 + const billedInTx = trackerRows.length > 0 ? toNumber(toDecimal(trackerRows[0].billed)) : 0 const remaining = Math.max(0, totalOverage - billedInTx) if (remaining === 0) { @@ -909,9 +919,7 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { .limit(1) const creditBalance = - lockedBalance.length > 0 - ? Number.parseFloat(lockedBalance[0].creditBalance?.toString() || '0') - : 0 + lockedBalance.length > 0 ? toNumber(toDecimal(lockedBalance[0].creditBalance)) : 0 const applied = Math.min(creditBalance, remaining) const billed = remaining - applied @@ -964,37 +972,18 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { const cents = Math.round(amountToBillStripe * 100) const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const finalizeIdemKey = `overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const payIdemKey = `overage-pay:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - const getPaymentMethodId = ( - pm: string | Stripe.PaymentMethod | null | undefined - ): string | undefined => (typeof pm === 'string' ? pm : pm?.id) + const { paymentMethodId: defaultPaymentMethod, collectionMethod } = + await resolveDefaultPaymentMethod(stripe, stripeSubscriptionId, customerId) - let collectionMethod: 'charge_automatically' | 'send_invoice' = 'charge_automatically' - let defaultPaymentMethod: string | undefined - try { - const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId) - if (stripeSub.collection_method === 'send_invoice') { - collectionMethod = 'send_invoice' - } - const subDpm = getPaymentMethodId(stripeSub.default_payment_method) - if (subDpm) { - defaultPaymentMethod = subDpm - } else if (collectionMethod === 'charge_automatically') { - const custObj = await stripe.customers.retrieve(customerId) - if (custObj && !('deleted' in custObj)) { - const cust = custObj as Stripe.Customer - const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method) - if (custDpm) defaultPaymentMethod = custDpm - } - } - } catch (e) { - logger.error('Failed to retrieve subscription or customer', { error: e }) - } + const effectiveCollectionMethod = collectionMethod ?? 'charge_automatically' const overageInvoice = await stripe.invoices.create( { customer: customerId, - collection_method: collectionMethod, + collection_method: effectiveCollectionMethod, auto_advance: false, ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), metadata: { @@ -1026,17 +1015,26 @@ export async function handleInvoiceFinalized(event: Stripe.Event) { if (typeof draftId !== 'string' || draftId.length === 0) { logger.error('Stripe created overage invoice without id; aborting finalize') } else { - const finalized = await stripe.invoices.finalizeInvoice(draftId) - if (collectionMethod === 'charge_automatically' && finalized.status === 'open') { + const finalized = await stripe.invoices.finalizeInvoice( + draftId, + {}, + { idempotencyKey: finalizeIdemKey } + ) + if ( + effectiveCollectionMethod === 'charge_automatically' && + finalized.status === 'open' + ) { try { const payId = finalized.id if (typeof payId !== 'string' || payId.length === 0) { logger.error('Finalized invoice missing id') throw new Error('Finalized invoice missing id') } - await stripe.invoices.pay(payId, { - payment_method: defaultPaymentMethod, - }) + await stripe.invoices.pay( + payId, + { payment_method: defaultPaymentMethod }, + { idempotencyKey: payIdemKey } + ) } catch (payError) { logger.error('Failed to auto-pay overage invoice', { error: payError, diff --git a/apps/sim/lib/billing/webhooks/outbox-handlers.ts b/apps/sim/lib/billing/webhooks/outbox-handlers.ts index 9effe40fb7e..64eb64a1973 100644 --- a/apps/sim/lib/billing/webhooks/outbox-handlers.ts +++ b/apps/sim/lib/billing/webhooks/outbox-handlers.ts @@ -2,8 +2,8 @@ import { db } from '@sim/db' import { subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import type Stripe from 'stripe' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method' import type { OutboxHandler } from '@/lib/core/outbox/service' const logger = createLogger('BillingOutboxHandlers') @@ -78,41 +78,6 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler { - const toId = (pm: string | Stripe.PaymentMethod | null | undefined): string | undefined => - typeof pm === 'string' ? pm : pm?.id - - try { - const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId) - const subPm = toId(sub.default_payment_method) - if (subPm) return subPm - - const customer = await stripe.customers.retrieve(customerId) - if (customer && !('deleted' in customer)) { - return toId((customer as Stripe.Customer).invoice_settings?.default_payment_method) - } - } catch (error) { - logger.warn('Failed to resolve default payment method', { - stripeSubscriptionId, - customerId, - error: error instanceof Error ? error.message : error, - }) - } - - return undefined -} - const stripeThresholdOverageInvoice: OutboxHandler = async ( payload, ctx @@ -124,7 +89,7 @@ const stripeThresholdOverageInvoice: OutboxHandler Date: Sat, 18 Apr 2026 10:41:08 -0700 Subject: [PATCH 11/11] minor improvement --- apps/sim/lib/billing/credits/purchase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/billing/credits/purchase.ts b/apps/sim/lib/billing/credits/purchase.ts index 9d1cf7167c9..496f3b0cbb1 100644 --- a/apps/sim/lib/billing/credits/purchase.ts +++ b/apps/sim/lib/billing/credits/purchase.ts @@ -193,7 +193,7 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise