From 72c2547e0ec7cd5b9f3bfaf27333eccc371f6b94 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 13:38:47 -0700 Subject: [PATCH 01/14] improvement(billing): make usage ledger-only and close cycles off period advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Usage is now the attributed usage_log ledger everywhere: the userStats baselines (currentPeriodCost / currentPeriodCopilotCost), the includeLegacyBaseline compatibility flag, the pro-snapshot join/leave machinery, and departedMemberUsage accrual are removed from all read and write paths. Cycle rollover (final sub-threshold overage collection, billedOverageThisPeriod reset, last-period bookkeeping) moves off the invoice.finalized payload parsing — dead for org subscriptions since May — onto a period-advance sweep with a durable per-subscription close marker (subscription.last_closed_period_start), transaction-enlisted Stripe outbox invoicing, and stamp-matched ledger sums. Enterprise closes are bookkeeping-only; reporting-anchor orgs advance the marker alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/api/cron/billing-cycle-close/route.ts | 57 + .../[id]/members/[memberId]/route.ts | 14 +- .../api/organizations/[id]/members/route.ts | 14 +- .../[id]/members/[memberId]/route.ts | 13 +- .../admin/organizations/[id]/members/route.ts | 15 +- .../api/v1/admin/users/[id]/billing/route.ts | 21 +- .../lib/admin/dashboard-organizations.test.ts | 9 +- apps/sim/lib/admin/dashboard.ts | 62 - .../api/contracts/v1/admin/organizations.ts | 4 +- apps/sim/lib/auth/auth.ts | 5 - .../calculations/usage-monitor.test.ts | 26 +- .../lib/billing/calculations/usage-monitor.ts | 74 +- apps/sim/lib/billing/core/billing.test.ts | 10 +- apps/sim/lib/billing/core/billing.ts | 221 +- .../billing/core/organization-usage.test.ts | 7 +- apps/sim/lib/billing/core/organization.ts | 58 +- apps/sim/lib/billing/core/usage-log.ts | 45 +- apps/sim/lib/billing/core/usage.ts | 117 +- apps/sim/lib/billing/credits/daily-refresh.ts | 2 +- apps/sim/lib/billing/cycle-close.test.ts | 315 + apps/sim/lib/billing/cycle-close.ts | 553 + .../billing/organizations/lock-order.test.ts | 17 +- .../lib/billing/organizations/membership.ts | 168 +- .../sim/lib/billing/threshold-billing.test.ts | 107 +- apps/sim/lib/billing/threshold-billing.ts | 128 +- .../sim/lib/billing/webhooks/invoices.test.ts | 48 - apps/sim/lib/billing/webhooks/invoices.ts | 570 +- apps/sim/lib/billing/webhooks/subscription.ts | 50 +- apps/sim/lib/logs/execution/logger.ts | 14 +- docker/crontab | 3 + helm/sim/values.yaml | 9 + ..._subscription_last_closed_period_start.sql | 1 + .../db/migrations/meta/0305_snapshot.json | 20120 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 36 +- packages/testing/src/mocks/schema.mock.ts | 1 + scripts/check-api-validation-contracts.ts | 1 + 37 files changed, 21378 insertions(+), 1544 deletions(-) create mode 100644 apps/sim/app/api/cron/billing-cycle-close/route.ts create mode 100644 apps/sim/lib/billing/cycle-close.test.ts create mode 100644 apps/sim/lib/billing/cycle-close.ts create mode 100644 packages/db/migrations/0305_add_subscription_last_closed_period_start.sql create mode 100644 packages/db/migrations/meta/0305_snapshot.json diff --git a/apps/sim/app/api/cron/billing-cycle-close/route.ts b/apps/sim/app/api/cron/billing-cycle-close/route.ts new file mode 100644 index 00000000000..9eddbb212ee --- /dev/null +++ b/apps/sim/app/api/cron/billing-cycle-close/route.ts @@ -0,0 +1,57 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { sweepBillingCycleCloses } from '@/lib/billing/cycle-close' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { runDetached } from '@/lib/core/utils/background' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('BillingCycleCloseCron') + +const LOCK_KEY = 'billing-cycle-close-lock' +/** Lock TTL in seconds — generous enough to cover the full sweep. */ +const LOCK_TTL_SECONDS = 15 * 60 + +export const dynamic = 'force-dynamic' + +/** + * Cron endpoint that closes elapsed billing periods (final overage collection, + * `billedOverageThisPeriod` reset, last-period bookkeeping). Configured in + * helm/sim/values.yaml under cronjobs.jobs.billingCycleClose. + * + * Acknowledges the cron call immediately and sweeps in the background; a Redis + * lock prevents overlapping runs, and each subscription's close is durably + * marked (`subscription.last_closed_period_start`), so replays are no-ops. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'Billing cycle close') + if (authError) { + return authError + } + + const lockValue = generateShortId() + const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, { + reclaimOnFailure: true, + }) + if (!locked) { + return NextResponse.json( + { success: true, message: 'Cycle-close sweep already in progress – skipped', status: 'skip' }, + { status: 202 } + ) + } + + runDetached('billing-cycle-close', async () => { + try { + const summary = await sweepBillingCycleCloses() + logger.info('Billing cycle-close sweep completed', { ...summary }) + } finally { + await releaseLock(LOCK_KEY, lockValue).catch(() => {}) + } + }) + + return NextResponse.json( + { success: true, message: 'Billing cycle-close sweep started', status: 'started' }, + { status: 202 } + ) +}) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 96942b68a89..1afe2fb1731 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -89,7 +89,6 @@ export const GET = withRouteHandler( if (includeUsage && hasAdminAccess) { const usageData = await db .select({ - currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, lastPeriodCost: userStats.lastPeriodCost, @@ -99,20 +98,19 @@ export const GET = withRouteHandler( .limit(1) if (usageData.length > 0) { - const { billingPeriod, includeLegacyBaseline, usageByUser } = - await getOrganizationMemberUsageSnapshot(organizationId, { + const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot( + organizationId, + { executor: dbReplica, userIds: [memberId], - }) + } + ) const memberLedger = usageByUser.get(memberId) ?? 0 memberData = { ...memberData, usage: { ...usageData[0], - currentPeriodCost: ( - (includeLegacyBaseline ? Number(usageData[0].currentPeriodCost ?? 0) : 0) + - memberLedger - ).toString(), + currentPeriodCost: memberLedger.toString(), billingPeriodStart: billingPeriod?.start ?? null, billingPeriodEnd: billingPeriod?.end ?? null, }, diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 9f146e1b9b8..894c0204a98 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -101,7 +101,6 @@ export const GET = withRouteHandler( createdAt: member.createdAt, userName: user.name, userEmail: user.email, - currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, }) @@ -115,19 +114,18 @@ export const GET = withRouteHandler( totalQuery, ]) - const { billingPeriod, includeLegacyBaseline, usageByUser } = - await getOrganizationMemberUsageSnapshot(organizationId, { + const { billingPeriod, usageByUser } = await getOrganizationMemberUsageSnapshot( + organizationId, + { userIds: base.map((row) => row.userId), - }) + } + ) const billingPeriodStart = billingPeriod?.start ?? null const billingPeriodEnd = billingPeriod?.end ?? null const membersWithUsage = base.map((row) => ({ ...row, - currentPeriodCost: ( - (includeLegacyBaseline ? Number(row.currentPeriodCost ?? 0) : 0) + - (usageByUser.get(row.userId) ?? 0) - ).toString(), + currentPeriodCost: (usageByUser.get(row.userId) ?? 0).toString(), billingPeriodStart, billingPeriodEnd, })) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 05bb34a6a59..779e14455b2 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -90,7 +90,6 @@ export const GET = withRouteHandler( createdAt: member.createdAt, userName: user.name, userEmail: user.email, - currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, billingBlocked: userStats.billingBlocked, }) @@ -104,10 +103,9 @@ export const GET = withRouteHandler( return notFoundResponse('Member') } - const { includeLegacyBaseline, usageByUser } = await getOrganizationMemberUsageSnapshot( - organizationId, - { userIds: [memberData.userId] } - ) + const { usageByUser } = await getOrganizationMemberUsageSnapshot(organizationId, { + userIds: [memberData.userId], + }) const data: AdminMemberDetail = { id: memberData.id, @@ -117,10 +115,7 @@ export const GET = withRouteHandler( createdAt: memberData.createdAt.toISOString(), userName: memberData.userName, userEmail: memberData.userEmail, - currentPeriodCost: ( - (includeLegacyBaseline ? Number(memberData.currentPeriodCost ?? 0) : 0) + - (usageByUser.get(memberData.userId) ?? 0) - ).toString(), + currentPeriodCost: (usageByUser.get(memberData.userId) ?? 0).toString(), currentUsageLimit: memberData.currentUsageLimit, billingBlocked: memberData.billingBlocked ?? false, } 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 6a7c1c17302..3ffe8289737 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 @@ -118,7 +118,6 @@ export const GET = withRouteHandler( createdAt: member.createdAt, userName: user.name, userEmail: user.email, - currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, billingBlocked: userStats.billingBlocked, }) @@ -133,12 +132,9 @@ export const GET = withRouteHandler( const total = countResult[0].count - const { includeLegacyBaseline, usageByUser } = await getOrganizationMemberUsageSnapshot( - organizationId, - { - userIds: membersData.map((row) => row.userId), - } - ) + const { usageByUser } = await getOrganizationMemberUsageSnapshot(organizationId, { + userIds: membersData.map((row) => row.userId), + }) const data: AdminMemberDetail[] = membersData.map((m) => ({ id: m.id, @@ -148,10 +144,7 @@ export const GET = withRouteHandler( createdAt: m.createdAt.toISOString(), userName: m.userName, userEmail: m.userEmail, - currentPeriodCost: ( - (includeLegacyBaseline ? Number(m.currentPeriodCost ?? 0) : 0) + - (usageByUser.get(m.userId) ?? 0) - ).toString(), + currentPeriodCost: (usageByUser.get(m.userId) ?? 0).toString(), currentUsageLimit: m.currentUsageLimit, billingBlocked: m.billingBlocked ?? false, })) 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 85a5f69b63a..7e9de1a4935 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 @@ -12,7 +12,8 @@ * Body: * - currentUsageLimit?: number | null - Usage limit (null to use default) * - billingBlocked?: boolean - Block/unblock billing - * - currentPeriodCost?: number - Reset/adjust current period cost (use with caution) + * - currentPeriodCost?: number - Deprecated no-op: usage is the attributed + * usage_log ledger and cannot be adjusted here * - reason?: string - Reason for the change (for audit logging) * * Response: AdminSingleResponse<{ success: true, updated: string[], warnings: string[] }> @@ -79,9 +80,8 @@ export const GET = withRouteHandler( const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1) - // currentPeriodCost is now only a baseline; canonical current-period usage - // (baseline + attributed usage_log, refresh-adjusted) comes from the same - // helper users see, so admin reflects real usage instead of a stale 0. + // Canonical current-period usage (attributed usage_log, refresh-adjusted) + // comes from the same helper users see. const usage = await getUserUsageData(userId) const memberOrgs = await db @@ -197,10 +197,10 @@ export const PATCH = withRouteHandler( if (currentUsageLimit === null) { updateData.currentUsageLimit = null } else { - const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0') - if (currentUsageLimit < currentCost) { + const { currentUsage } = await getUserUsageData(userId) + if (currentUsageLimit < currentUsage) { warnings.push( - `New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.` + `New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentUsage.toFixed(2)}). User may be immediately blocked.` ) } updateData.currentUsageLimit = currentUsageLimit.toFixed(2) @@ -225,13 +225,9 @@ export const PATCH = withRouteHandler( } if (currentPeriodCost !== undefined) { - const previousCost = existingStats?.currentPeriodCost || '0' warnings.push( - `Manually adjusting currentPeriodCost from $${previousCost} to $${currentPeriodCost.toFixed(2)}. This may affect billing accuracy.` + 'currentPeriodCost adjustments are deprecated: usage is the attributed usage_log ledger and cannot be edited here. The field was ignored.' ) - - updateData.currentPeriodCost = currentPeriodCost.toFixed(2) - updated.push('currentPeriodCost') } if (updated.length === 0) { @@ -256,7 +252,6 @@ export const PATCH = withRouteHandler( ? { currentUsageLimit: existingStats.currentUsageLimit, billingBlocked: existingStats.billingBlocked, - currentPeriodCost: existingStats.currentPeriodCost, } : null, newValues: updateData, diff --git a/apps/sim/lib/admin/dashboard-organizations.test.ts b/apps/sim/lib/admin/dashboard-organizations.test.ts index 49de9707c93..6f7a49f841c 100644 --- a/apps/sim/lib/admin/dashboard-organizations.test.ts +++ b/apps/sim/lib/admin/dashboard-organizations.test.ts @@ -291,13 +291,13 @@ describe('listDashboardOrganizations', () => { externalCollaboratorCount: 0, planLabel: 'No plan', }) - // Pagination, membership/collaborators, and two batched usage aggregates. + // Pagination, membership/collaborators, and the batched ledger aggregate. // This count remains constant regardless of the number of organizations. - expect(dbChainMockFns.select).toHaveBeenCalledTimes(6) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(5) expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalledTimes(1) }) - it('preserves the frozen baseline for an Enterprise subscription using its Stripe period', async () => { + it('reports ledger usage for an Enterprise subscription using its Stripe period', async () => { queueTableRows(organization, [{ total: 1 }]) queueTableRows(organization, [ { id: 'org-1', name: 'One', orgUsageLimit: '100', creditBalance: '0' }, @@ -325,13 +325,12 @@ describe('listDashboardOrganizations', () => { }, ]) queueTableRows(usageLog, [{ organizationId: 'org-1', cost: '2.5', workflowRuns: 3 }]) - queueTableRows(member, [{ organizationId: 'org-1', cost: '1.5' }]) const result = await listDashboardOrganizations({ search: '', limit: 50, offset: 0 }) expect(result.data[0]).toMatchObject({ reportingPeriod: { source: 'stripe' }, - usage: { usedDollars: 4, workflowRuns: 3 }, + usage: { usedDollars: 2.5, workflowRuns: 3 }, }) }) diff --git a/apps/sim/lib/admin/dashboard.ts b/apps/sim/lib/admin/dashboard.ts index 144fe0def4d..bdb62e65b72 100644 --- a/apps/sim/lib/admin/dashboard.ts +++ b/apps/sim/lib/admin/dashboard.ts @@ -254,24 +254,6 @@ async function getDashboardOrganizationUsage( } } - const legacyOrganizationIds = contexts - .filter((context) => context.period.source !== 'reporting') - .map((context) => context.organizationId) - if (legacyOrganizationIds.length > 0) { - const baselineTotals = await db - .select({ - organizationId: member.organizationId, - cost: sql`coalesce(sum(${userStats.currentPeriodCost}), 0)`, - }) - .from(member) - .leftJoin(userStats, eq(userStats.userId, member.userId)) - .where(inArray(member.organizationId, legacyOrganizationIds)) - .groupBy(member.organizationId) - for (const row of baselineTotals) { - const usage = result.get(row.organizationId) - if (usage) usage.total += Number(row.cost) - } - } return result } @@ -307,34 +289,6 @@ async function getDashboardOrganizationUsage( ) } - const legacyOrganizationIds = contexts - .filter((context) => context.period.source !== 'reporting') - .map((context) => context.organizationId) - if (legacyOrganizationIds.length > 0) { - const baselineRows = await db - .select({ - organizationId: member.organizationId, - userId: member.userId, - cost: userStats.currentPeriodCost, - }) - .from(member) - .leftJoin(userStats, eq(userStats.userId, member.userId)) - .where( - options.userIds - ? and( - inArray(member.organizationId, legacyOrganizationIds), - inArray(member.userId, options.userIds) - ) - : inArray(member.organizationId, legacyOrganizationIds) - ) - for (const row of baselineRows) { - const usage = result.get(row.organizationId) - if (!usage) continue - const amount = Number(row.cost ?? 0) - usage.total += amount - usage.byUser.set(row.userId, (usage.byUser.get(row.userId) ?? 0) + amount) - } - } return result } @@ -661,22 +615,6 @@ export async function listDashboardUsers({ search, limit, offset }: PaginationIn : [] ) ) - const legacyPersonalIds = personalUserIds.filter( - (userId) => personalPeriods.get(userId)?.source !== 'reporting' - ) - if (legacyPersonalIds.length > 0) { - const baselineRows = await db - .select({ userId: userStats.userId, cost: userStats.currentPeriodCost }) - .from(userStats) - .where(inArray(userStats.userId, legacyPersonalIds)) - for (const row of baselineRows) { - const current = personalUsage.get(row.userId) ?? { dollars: 0, workflowRuns: 0 } - personalUsage.set(row.userId, { - ...current, - dollars: current.dollars + Number(row.cost ?? 0), - }) - } - } return { data: rows.map((row) => { diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index af86a5f40c1..434daa41a9b 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -137,6 +137,7 @@ export const adminV1TransferOwnershipBodySchema = z.object({ const adminV1OrganizationMemberMutationResultSchema = adminV1MemberSchema.extend({ action: z.enum(['created', 'updated', 'already_member']), billingActions: z.object({ + /** @deprecated Always false — ledger entity stamps replaced join-time snapshots. */ proUsageSnapshotted: z.boolean(), proCancelledAtPeriodEnd: z.boolean(), }), @@ -147,9 +148,10 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({ memberId: z.string(), userId: z.string(), billingActions: z.object({ - /** Dollar amount of departed-member usage captured (0 when none). */ + /** @deprecated Always 0 — a departed member's ledger rows stay stamped to the org's period. */ usageCaptured: z.number(), proRestored: z.boolean(), + /** @deprecated Always false — no snapshot exists to restore. */ usageRestored: z.boolean(), skipBillingLogic: z.boolean(), }), diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 2f65fc1bbf5..152a8354199 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -70,7 +70,6 @@ import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout' import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes' import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise' import { - handleInvoiceFinalized, handleInvoicePaymentFailed, handleInvoicePaymentSucceeded, } from '@/lib/billing/webhooks/invoices' @@ -1502,10 +1501,6 @@ export const auth = betterAuth({ await handleInvoicePaymentFailed(event) break } - case 'invoice.finalized': { - await handleInvoiceFinalized(event) - break - } case 'customer.subscription.created': case 'customer.subscription.updated': { await handleManualEnterpriseSubscription(event) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index bbd0a7a0a78..c1c62f3f11c 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -8,7 +8,7 @@ const { mockGetBillingPeriodUsageCost, mockGetOrgMemberUsageForBillingPeriod, mockGetOrgMemberUsageLimit, - mockGetPooledOrgCurrentPeriodCost, + mockGetOrgMemberBillingRollup, mockGetUserUsageLimit, mockIsOrganizationBillingBlocked, mockComputeBillingPeriodUsageWithDailyRefresh, @@ -17,7 +17,7 @@ const { mockGetBillingPeriodUsageCost: vi.fn(), mockGetOrgMemberUsageForBillingPeriod: vi.fn(), mockGetOrgMemberUsageLimit: vi.fn(), - mockGetPooledOrgCurrentPeriodCost: vi.fn(), + mockGetOrgMemberBillingRollup: vi.fn(), mockGetUserUsageLimit: vi.fn(), mockIsOrganizationBillingBlocked: vi.fn(), mockComputeBillingPeriodUsageWithDailyRefresh: vi.fn(), @@ -36,7 +36,7 @@ vi.mock('@/lib/billing/core/access', () => ({ // core/usage pulls in the email-rendering chain at import; stub the two symbols // usage-monitor imports from it so the module loads in a node test env. vi.mock('@/lib/billing/core/usage', () => ({ - getPooledOrgCurrentPeriodCost: mockGetPooledOrgCurrentPeriodCost, + getOrgMemberBillingRollup: mockGetOrgMemberBillingRollup, getUserUsageLimit: mockGetUserUsageLimit, })) @@ -109,7 +109,7 @@ describe('checkUsageStatus', () => { { type: 'organization', id: 'org-1' }, billingPeriod ) - expect(mockGetPooledOrgCurrentPeriodCost).not.toHaveBeenCalled() + expect(mockGetOrgMemberBillingRollup).not.toHaveBeenCalled() }) it('reads paid personal ledger usage and refresh from one snapshot', async () => { @@ -123,10 +123,8 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - dbChainMockFns.limit.mockResolvedValueOnce([{ currentPeriodCost: '20' }]) - await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({ - currentUsage: 120, + currentUsage: 100, scope: 'user', }) @@ -152,7 +150,6 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - dbChainMockFns.limit.mockResolvedValueOnce([{ currentPeriodCost: '0' }]) mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValueOnce({ ledgerUsage: -1, refreshConsumed: 1, @@ -175,10 +172,8 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - dbChainMockFns.limit.mockResolvedValueOnce([{ currentPeriodCost: '20' }]) - await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({ - currentUsage: 145, + currentUsage: 125, scope: 'user', }) @@ -200,7 +195,6 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - dbChainMockFns.limit.mockResolvedValueOnce([{ currentPeriodCost: '0' }]) mockGetBillingPeriodUsageCost.mockResolvedValueOnce(-1) await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({ @@ -223,9 +217,9 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - mockGetPooledOrgCurrentPeriodCost.mockResolvedValue({ + mockGetOrgMemberBillingRollup.mockResolvedValue({ memberIds: ['user-1', 'user-2'], - currentPeriodCost: 20, + lastPeriodCost: 0, }) mockGetOrgMemberRefreshBounds.mockResolvedValue({ 'user-2': { userStart } }) mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValue({ @@ -234,7 +228,7 @@ describe('checkUsageStatus', () => { }) await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({ - currentUsage: 110, + currentUsage: 90, scope: 'organization', organizationId: 'org-1', }) @@ -267,7 +261,7 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - mockGetPooledOrgCurrentPeriodCost.mockResolvedValue({ memberIds: [], currentPeriodCost: 0 }) + mockGetOrgMemberBillingRollup.mockResolvedValue({ memberIds: [], lastPeriodCost: 0 }) await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({ currentUsage: 125, diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index c9b84671a12..48128a8493e 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -8,7 +8,7 @@ import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period' import { - getPooledOrgCurrentPeriodCost, + getOrgMemberBillingRollup, getUserUsageLimit, type UsageLimitSubscription, } from '@/lib/billing/core/usage' @@ -29,7 +29,6 @@ import { } from '@/lib/billing/organizations/member-limits' 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, isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('UsageMonitor') @@ -64,34 +63,19 @@ async function computePooledOrgUsage( anchorDate: null, interval: null, } - if (billingPeriod.source === 'reporting') { - const ledgerUsage = await getBillingPeriodUsageCost( - { type: 'organization', id: organizationId }, - billingPeriod - ) - return ledgerUsage - } - - const { memberIds, currentPeriodCost } = await getPooledOrgCurrentPeriodCost(organizationId) - if (memberIds.length === 0) { - return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) - } if (!isPaid(sub.plan) || !sub.periodStart) { - const ledgerUsage = await getBillingPeriodUsageCost( - { type: 'organization', id: organizationId }, - billingPeriod - ) - return currentPeriodCost + ledgerUsage + return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) } const planDollars = getPlanTierDollars(sub.plan) if (planDollars <= 0) { - const ledgerUsage = await getBillingPeriodUsageCost( - { type: 'organization', id: organizationId }, - billingPeriod - ) - return currentPeriodCost + ledgerUsage + return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) + } + + const { memberIds } = await getOrgMemberBillingRollup(organizationId) + if (memberIds.length === 0) { + return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) } const userBounds = await getOrgMemberRefreshBounds(organizationId, sub.periodStart) @@ -106,7 +90,7 @@ async function computePooledOrgUsage( userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) - return Math.max(0, currentPeriodCost + ledgerUsage - refreshConsumed) + return Math.max(0, ledgerUsage - refreshConsumed) } /** @@ -120,9 +104,11 @@ export async function checkUsageStatus( ): Promise { try { if (!isBillingEnabled) { - const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) - const currentUsage = - statsRecords.length > 0 ? toNumber(toDecimal(statsRecords[0].currentPeriodCost)) : 0 + // Self-hosted display: lifetime ledger over the open default window. + const currentUsage = await getBillingPeriodUsageCost( + { type: 'user', id: userId }, + { ...defaultBillingPeriod(), source: 'default' } + ) return { percentUsed: Math.min((currentUsage / 1000) * 100, 100), @@ -156,25 +142,6 @@ export async function checkUsageStatus( return buildUsageData({ currentUsage, limit, scope, organizationId }) } - const statsRecords = await db - .select() - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - 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 billingPeriod = preloadedBillingContext?.billingPeriod ?? (sub?.periodStart && sub.periodEnd @@ -203,8 +170,7 @@ export async function checkUsageStatus( } else { ledgerUsage = await getBillingPeriodUsageCost({ type: 'user', id: userId }, billingPeriod) } - const usageBeforeRefresh = - toNumber(toDecimal(statsRecords[0].currentPeriodCost)) + ledgerUsage - refreshConsumed + const usageBeforeRefresh = ledgerUsage - refreshConsumed const currentUsage = appliedDailyRefresh ? Math.max(0, usageBeforeRefresh) : usageBeforeRefresh return buildUsageData({ currentUsage, limit, scope, organizationId }) @@ -364,17 +330,9 @@ export async function checkServerSideUsageLimits( logger.info('Server-side checking usage limits for user', { userId }) - const stats = await db - .select({ current: userStats.currentPeriodCost }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - const currentUsage = stats.length > 0 ? toNumber(toDecimal(stats[0].current)) : 0 - const blocked = await checkBillingBlocked(userId) if (blocked.blocked) { - return { isExceeded: true, currentUsage, limit: 0, message: blocked.message } + return { isExceeded: true, currentUsage: 0, limit: 0, message: blocked.message } } const usageData = await checkUsageStatus(userId, preloadedSubscription, preloadedBillingContext) diff --git a/apps/sim/lib/billing/core/billing.test.ts b/apps/sim/lib/billing/core/billing.test.ts index ea431214e91..84078888d00 100644 --- a/apps/sim/lib/billing/core/billing.test.ts +++ b/apps/sim/lib/billing/core/billing.test.ts @@ -52,8 +52,8 @@ describe('getPersonalBillingSummary', () => { vi.clearAllMocks() mockEnsureUserStatsExists.mockResolvedValue(undefined) mockResolveBillingInterval.mockReturnValue('year') - mockComputeDailyRefreshConsumed.mockResolvedValue(3) - mockGetBillingPeriodUsageCostWithSourceSubset.mockResolvedValue({ total: 2, subset: 1 }) + mockComputeDailyRefreshConsumed.mockResolvedValue(1) + mockGetBillingPeriodUsageCostWithSourceSubset.mockResolvedValue({ total: 4, subset: 1 }) mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ id: 'personal-sub', referenceId: 'viewer-a', @@ -74,12 +74,8 @@ describe('getPersonalBillingSummary', () => { }) dbChainMockFns.limit.mockResolvedValueOnce([ { - currentPeriodCost: '10', currentUsageLimit: '30', lastPeriodCost: '6', - proPeriodCostSnapshot: '4', - proPeriodCostSnapshotAt: new Date('2026-07-10T00:00:00.000Z'), - currentPeriodCopilotCost: '5', lastPeriodCopilotCost: '2', creditBalance: '7', billingBlocked: true, @@ -117,7 +113,7 @@ describe('getPersonalBillingSummary', () => { }) expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith( expect.objectContaining({ - periodEnd: new Date('2026-07-10T00:00:00.000Z'), + periodEnd: new Date('2026-08-01T00:00:00.000Z'), billingEntity: { type: 'user', id: 'viewer-a' }, }), dbChainMock.db diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 8372293a0f9..2e28e5099d7 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -113,70 +113,11 @@ export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Pro } /** - * 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. - * - * 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, - executor: DbClient = db -): Promise<{ - memberIds: string[] - currentPeriodCost: number - currentPeriodCopilotCost: number - lastPeriodCopilotCost: number -}> { - const rows = await executor - .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) - // Copilot baseline (copilot source). All copilot-family usage (incl. MCP) lives - // in usage_log and is added via the copilot ledger by callers — not a baseline. - 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 { - memberIds, - currentPeriodCost: toNumber(currentPeriodCost), - currentPeriodCopilotCost: toNumber(currentPeriodCopilotCost), - lastPeriodCopilotCost: toNumber(lastPeriodCopilotCost), - } -} - -/** - * 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. + * Compute an org's overage amount from an already-fetched pooled ledger sum. + * Internally performs one daily-refresh DB read to subtract refresh credits; + * callers pass the org-attributed ledger usage for the period (threshold + * billing passes the current period; cycle close passes the closed period). + * All callers route through this to keep the overage math in one place. */ export async function computeOrgOverageAmount(params: { plan: string | null @@ -184,8 +125,7 @@ export async function computeOrgOverageAmount(params: { periodStart: Date | null periodEnd: Date | null organizationId: string - pooledCurrentPeriodCost: number - departedMemberUsage: number + pooledLedgerUsage: number memberIds: string[] }): Promise<{ effectiveUsage: number @@ -193,7 +133,7 @@ export async function computeOrgOverageAmount(params: { dailyRefreshDeduction: number totalOverage: number }> { - const totalUsage = params.pooledCurrentPeriodCost + params.departedMemberUsage + const totalUsage = params.pooledLedgerUsage let dailyRefreshDeduction = 0 const planDollars = getPlanTierDollars(params.plan) @@ -244,7 +184,11 @@ export async function calculateSubscriptionOverage(sub: { const isOrgScoped = await isSubscriptionOrgScoped(sub) if (isOrgScoped) { - const pooled = await aggregateOrgMemberStats(sub.referenceId) + const memberRows = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + const memberIds = memberRows.map((row) => row.userId) const ledgerUsage = sub.periodStart && sub.periodEnd ? await getBillingPeriodUsageCost( @@ -253,24 +197,14 @@ export async function calculateSubscriptionOverage(sub: { ) : 0 - const orgData = await db - .select({ departedMemberUsage: organization.departedMemberUsage }) - .from(organization) - .where(eq(organization.id, sub.referenceId)) - .limit(1) - - const departedMemberUsage = - orgData.length > 0 ? toNumber(toDecimal(orgData[0].departedMemberUsage)) : 0 - 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 + ledgerUsage, - departedMemberUsage, - memberIds: pooled.memberIds, + pooledLedgerUsage: ledgerUsage, + memberIds, }) totalOverageDecimal = toDecimal(totalOverage) @@ -278,33 +212,18 @@ export async function calculateSubscriptionOverage(sub: { logger.info('Calculated org-scoped overage', { subscriptionId: sub.id, plan: sub.plan, - currentMemberUsage: pooled.currentPeriodCost + ledgerUsage, - departedMemberUsage, ledgerUsage, - totalUsage: pooled.currentPeriodCost + ledgerUsage + departedMemberUsage, effectiveUsage, baseSubscriptionAmount, totalOverage, }) - } else if (isPro(sub.plan)) { - // 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, - proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, - proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt, - }) - .from(userStats) - .where(eq(userStats.userId, sub.referenceId)) - .limit(1) - - const personalCurrentUsage = statsRow ? toNumber(toDecimal(statsRow.currentPeriodCost)) : 0 - const snapshotUsage = statsRow ? toNumber(toDecimal(statsRow.proPeriodCostSnapshot)) : 0 - const snapshotAt = statsRow?.proPeriodCostSnapshotAt ?? null + } else { + // Ledger sums are read for the exact reference user (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 invoice. Ledger entity stamps already attribute + // post-org-join usage to the org, so the personal sum excludes it. const ledgerUsage = sub.periodStart && sub.periodEnd ? await getBillingPeriodUsageCost( @@ -313,82 +232,31 @@ export async function calculateSubscriptionOverage(sub: { ) : 0 - const joinedOrgMidCycle = snapshotAt !== null || snapshotUsage > 0 - const totalProUsageDecimal = joinedOrgMidCycle - ? toDecimal(snapshotUsage).plus(ledgerUsage) - : toDecimal(personalCurrentUsage).plus(ledgerUsage) - - if (joinedOrgMidCycle) { - logger.info('Billing personal Pro only for pre-join usage (user joined org mid-cycle)', { - userId: sub.referenceId, - preJoinUsage: snapshotUsage, - postJoinUsageOnMemberRow: personalCurrentUsage, - snapshotAt: snapshotAt?.toISOString() ?? null, - subscriptionId: sub.id, - }) - } - 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: refreshCap, - planDollars, - billingEntity: { type: 'user', id: sub.referenceId }, - }) + if (isPro(sub.plan)) { + const planDollars = getPlanTierDollars(sub.plan) + if (planDollars > 0 && sub.periodStart) { + dailyRefreshDeduction = await computeDailyRefreshConsumed({ + userIds: [sub.referenceId], + periodStart: sub.periodStart, + periodEnd: sub.periodEnd ?? null, + planDollars, + billingEntity: { type: 'user', id: sub.referenceId }, + }) + } } - const effectiveUsageDecimal = Decimal.max( - 0, - totalProUsageDecimal.minus(toDecimal(dailyRefreshDeduction)) - ) - const { basePrice } = getPlanPricing(sub.plan ?? '') - totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(basePrice)) - - logger.info('Calculated personal pro overage', { - subscriptionId: sub.id, - joinedOrgMidCycle, - personalCurrentUsage, - snapshot: snapshotUsage, - ledgerUsage, - billedUsage: toNumber(totalProUsageDecimal), - dailyRefreshDeduction, - basePrice, - totalOverage: toNumber(totalOverageDecimal), - }) - } else { - // Free or unknown plan. Same direct-read rationale as the Pro branch. - 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 ledgerUsage = - sub.periodStart && sub.periodEnd - ? await getBillingPeriodUsageCost( - { type: 'user', id: sub.referenceId }, - { start: sub.periodStart, end: sub.periodEnd } - ) - : 0 const { basePrice } = getPlanPricing(sub.plan || 'free') totalOverageDecimal = Decimal.max( 0, - toDecimal(personalCurrentUsage).plus(ledgerUsage).minus(basePrice) + toDecimal(ledgerUsage).minus(toDecimal(dailyRefreshDeduction)).minus(basePrice) ) - logger.info('Calculated overage for plan', { + logger.info('Calculated personal overage', { subscriptionId: sub.id, plan: sub.plan || 'free', - usage: personalCurrentUsage + ledgerUsage, ledgerUsage, + dailyRefreshDeduction, basePrice, totalOverage: toNumber(totalOverageDecimal), }) @@ -410,12 +278,8 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie getHighestPriorityPersonalSubscription(userId, { executor }), db .select({ - currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, lastPeriodCost: userStats.lastPeriodCost, - proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, - proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt, - currentPeriodCopilotCost: userStats.currentPeriodCopilotCost, lastPeriodCopilotCost: userStats.lastPeriodCopilotCost, creditBalance: userStats.creditBalance, billingBlocked: userStats.billingBlocked, @@ -444,12 +308,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie executor ) - const hasPersonalUsageSnapshot = - Boolean(personalSubscription) && isPro(plan) && stats.proPeriodCostSnapshotAt !== null - const personalUsageBaseline = hasPersonalUsageSnapshot - ? stats.proPeriodCostSnapshot - : stats.currentPeriodCost - const currentUsage = toDecimal(personalUsageBaseline).plus(ledgerUsage) + const currentUsage = toDecimal(ledgerUsage) let refreshDeduction = 0 if ( @@ -464,9 +323,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie { userIds: [userId], periodStart: personalSubscription.periodStart, - periodEnd: hasPersonalUsageSnapshot - ? stats.proPeriodCostSnapshotAt - : (personalSubscription.periodEnd ?? null), + periodEnd: personalSubscription.periodEnd ?? null, planDollars, billingEntity: { type: 'user', id: userId }, }, @@ -528,9 +385,7 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie lastPeriodCost: toNumber(toDecimal(stats.lastPeriodCost)), lastPeriodCopilotCost: toNumber(toDecimal(stats.lastPeriodCopilotCost)), daysRemaining, - copilotCost: - (hasPersonalUsageSnapshot ? 0 : toNumber(toDecimal(stats.currentPeriodCopilotCost))) + - copilotLedgerUsage, + copilotCost: copilotLedgerUsage, }, } } catch (error) { diff --git a/apps/sim/lib/billing/core/organization-usage.test.ts b/apps/sim/lib/billing/core/organization-usage.test.ts index fc65277219a..b1838a49664 100644 --- a/apps/sim/lib/billing/core/organization-usage.test.ts +++ b/apps/sim/lib/billing/core/organization-usage.test.ts @@ -28,7 +28,7 @@ describe('getOrganizationMemberUsageSnapshot', () => { afterEach(() => vi.useRealTimers()) - it('uses the Enterprise reporting window and excludes the legacy baseline', async () => { + it('uses the Enterprise reporting window for anchored organizations', async () => { getOrganizationSubscription.mockResolvedValue({ plan: 'enterprise', billingInterval: 'year', @@ -46,7 +46,6 @@ describe('getOrganizationMemberUsageSnapshot', () => { start: new Date('2026-01-01T00:00:00.000Z'), end: new Date('2027-01-01T00:00:00.000Z'), }) - expect(snapshot.includeLegacyBaseline).toBe(false) expect(getBillingPeriodUsageCostByUser).toHaveBeenCalledWith( { type: 'organization', id: 'org-1' }, expect.objectContaining({ source: 'reporting' }), @@ -56,7 +55,7 @@ describe('getOrganizationMemberUsageSnapshot', () => { ) }) - it('uses Stripe dates and retains the legacy baseline without custom reporting metadata', async () => { + it('uses Stripe dates without custom reporting metadata', async () => { const periodStart = new Date('2026-08-01T00:00:00.000Z') const periodEnd = new Date('2026-09-01T00:00:00.000Z') getOrganizationSubscription.mockResolvedValue({ @@ -76,6 +75,6 @@ describe('getOrganizationMemberUsageSnapshot', () => { anchorDate: null, interval: 'month', }) - expect(snapshot.includeLegacyBaseline).toBe(true) + expect(snapshot.usageByUser).toEqual(new Map([['user-1', 12.5]])) }) }) diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index 1a066efae6f..c3f02cce9a1 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -67,11 +67,10 @@ interface MemberUsageData { } /** - * Per-member usage_log cost for an org's current billing period, keyed by userId. - * `currentPeriodCost` is only a baseline (no longer incremented on the hot path), - * so callers add this ledger component to it for each member's real current-period - * usage. Pass `period` to reuse an already-fetched subscription window; omit it to - * look up the org's subscription here. Returns an empty map when there's no period. + * Per-member usage_log cost for an org's current billing period, keyed by + * userId — each member's real current-period usage. Pass `period` to reuse an + * already-fetched subscription window; omit it to look up the org's + * subscription here. Returns an empty map when there's no period. */ export async function getOrgMemberLedgerByUser( organizationId: string, @@ -96,7 +95,6 @@ export async function getOrgMemberLedgerByUser( export interface OrganizationMemberUsageSnapshot { billingPeriod: UsageQueryPeriod | null - includeLegacyBaseline: boolean usageByUser: Map } @@ -106,13 +104,9 @@ const MAX_ORGANIZATION_BILLING_MEMBER_LIMIT = 100 async function getOrganizationMemberUsageCounts( organizationId: string, billingPeriod: UsageQueryPeriod, - includeLegacyBaseline: boolean, executor: DbClient ): Promise<{ overLimit: number; nearLimit: number }> { - const currentUsage = sql`( - ${includeLegacyBaseline ? sql`coalesce(${userStats.currentPeriodCost}, 0)` : sql`0`} + - coalesce(sum(${usageLog.cost}), 0) - )` + const currentUsage = sql`coalesce(sum(${usageLog.cost}), 0)` .mapWith(Number) .as('current_usage') const usageLimit = sql`coalesce(${userStats.currentUsageLimit}, ${getFreeTierLimit()})` @@ -140,7 +134,7 @@ async function getOrganizationMemberUsageCounts( ) ) .where(eq(member.organizationId, organizationId)) - .groupBy(member.userId, userStats.currentPeriodCost, userStats.currentUsageLimit) + .groupBy(member.userId, userStats.currentUsageLimit) .as('organization_member_usage') const [counts] = await executor @@ -164,8 +158,7 @@ async function getOrganizationMemberUsageCounts( /** * Resolves the organization's usage period once and returns the ledger usage - * for only the requested actors. Reporting periods never include the legacy - * userStats baseline; Stripe/default periods retain it for compatibility. + * for only the requested actors. */ export async function getOrganizationMemberUsageSnapshot( organizationId: string, @@ -179,7 +172,6 @@ export async function getOrganizationMemberUsageSnapshot( const billingPeriod = subscription ? resolveSubscriptionUsagePeriodOrDefault(subscription) : null return { billingPeriod, - includeLegacyBaseline: billingPeriod?.source !== 'reporting', usageByUser: billingPeriod ? await getOrgMemberLedgerByUser(organizationId, billingPeriod, executor, options.userIds) : new Map(), @@ -218,7 +210,6 @@ export async function getOrganizationBillingData( } const billingPeriod = resolveSubscriptionUsagePeriodOrDefault(subscription) - const includeLegacyBaseline = billingPeriod?.source !== 'reporting' const limit = Math.min( MAX_ORGANIZATION_BILLING_MEMBER_LIMIT, Math.max(1, memberPage.limit ?? DEFAULT_ORGANIZATION_BILLING_MEMBER_LIMIT) @@ -226,12 +217,8 @@ export async function getOrganizationBillingData( const offset = Math.max(0, memberPage.offset ?? 0) const [memberAggregateRows, membersWithUsage] = await Promise.all([ executor - .select({ - total: count(), - baseline: sql`coalesce(sum(${userStats.currentPeriodCost}), 0)`, - }) + .select({ total: count() }) .from(member) - .leftJoin(userStats, eq(member.userId, userStats.userId)) .where(eq(member.organizationId, organizationId)), executor .select({ @@ -240,7 +227,6 @@ export async function getOrganizationBillingData( userEmail: user.email, role: member.role, joinedAt: member.createdAt, - currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, }) .from(member) @@ -257,9 +243,7 @@ export async function getOrganizationBillingData( : new Map() const members: MemberUsageData[] = membersWithUsage.map((memberRecord) => { - const currentUsage = - (includeLegacyBaseline ? Number(memberRecord.currentPeriodCost || 0) : 0) + - (usageByUser.get(memberRecord.userId) ?? 0) + const currentUsage = usageByUser.get(memberRecord.userId) ?? 0 const usageLimit = Number(memberRecord.currentUsageLimit || getFreeTierLimit()) const percentUsed = usageLimit > 0 ? (currentUsage / usageLimit) * 100 : 0 @@ -278,15 +262,14 @@ export async function getOrganizationBillingData( const memberAggregate = memberAggregateRows[0] const membersTotal = memberAggregate?.total ?? 0 - let totalCurrentUsage = includeLegacyBaseline ? Number(memberAggregate?.baseline ?? 0) : 0 - if (billingPeriod) { - totalCurrentUsage += await getBillingPeriodUsageCost( - { type: 'organization', id: subscription.referenceId }, - billingPeriod, - undefined, - executor - ) - } + let totalCurrentUsage = billingPeriod + ? await getBillingPeriodUsageCost( + { type: 'organization', id: subscription.referenceId }, + billingPeriod, + undefined, + executor + ) + : 0 if (isPaid(subscription.plan) && subscription.periodStart) { const planDollars = getPlanTierDollars(subscription.plan) @@ -338,12 +321,7 @@ export async function getOrganizationBillingData( const pendingSeats = await countPendingSeatInvitations(organizationId, executor) const usedSeats = membersTotal + pendingSeats const memberUsageCounts = billingPeriod - ? await getOrganizationMemberUsageCounts( - organizationId, - billingPeriod, - includeLegacyBaseline, - executor - ) + ? await getOrganizationMemberUsageCounts(organizationId, billingPeriod, executor) : { overLimit: 0, nearLimit: 0 } const billingPeriodStart = billingPeriod?.start ?? null diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 285bb827ca1..552f15b4e2f 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -180,8 +180,8 @@ async function resolveBillingContext( } /** - * Returns post-cutover usage for an attributed billing entity/period. - * Legacy pre-cutover usage remains in userStats as a baseline until reset. + * Returns attributed ledger usage for a billing entity/period. The ledger is + * the sole source of truth for usage — there is no userStats baseline. */ export async function getBillingPeriodUsageCost( billingEntity: BillingEntity, @@ -333,6 +333,47 @@ export async function getBillingPeriodUsageCostByUser( return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')])) } +/** + * Per-user ledger cost for every stamped billing period fully contained in + * `[from, to]`. Rows are matched on their write-time period stamps + * (`billing_period_start >= from AND billing_period_end <= to`), not on + * `created_at`, so a row written moments after rollover but stamped with the + * prior period is still attributed to that prior period. + * + * Used by the cycle-close sweep, whose window is normally exactly one period + * (`from` = the closed period's start, `to` = its end == the current period's + * start); a wider window absorbs multi-period catch-up after missed sweeps. + */ +export async function getStampedPeriodRangeUsageCostByUser( + billingEntity: BillingEntity, + range: { from: Date; to: Date }, + source?: UsageLogSource | UsageLogSource[], + executor: DbClient = db +): Promise> { + const conditions = [ + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + gte(usageLog.billingPeriodStart, range.from), + lte(usageLog.billingPeriodEnd, range.to), + ] + if (source) { + conditions.push( + Array.isArray(source) ? inArray(usageLog.source, source) : eq(usageLog.source, source) + ) + } + + const rows = await executor + .select({ + userId: usageLog.userId, + cost: sql`COALESCE(SUM(${usageLog.cost}), 0)`, + }) + .from(usageLog) + .where(and(...conditions)) + .groupBy(usageLog.userId) + + return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')])) +} + /** * Records usage as append-only billing events. * diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index d0018768697..86b2684b43a 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -27,7 +27,7 @@ import { computeDailyRefreshConsumed, getOrgMemberRefreshBounds, } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isEnterprise, isFree, isPaid, isPro } from '@/lib/billing/plan-helpers' +import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers' import { canEditUsageLimit, getFreeTierLimit, @@ -66,43 +66,37 @@ export interface UsageLimitSubscription { } /** - * 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). + * Member ids plus the pooled previous-period bookkeeping total for an + * organization. Current-period usage is never read here — it is always the + * attributed usage_log ledger. `lastPeriodCost` rows are written by the + * cycle-close sweep from ledger sums. * * 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( +export async function getOrgMemberBillingRollup( organizationId: string, executor: DbClient = db -): Promise<{ memberIds: string[]; currentPeriodCost: number; lastPeriodCost: number }> { +): Promise<{ memberIds: string[]; lastPeriodCost: number }> { const rows = await executor .select({ userId: member.userId, - currentPeriodCost: userStats.currentPeriodCost, lastPeriodCost: userStats.lastPeriodCost, }) .from(member) .leftJoin(userStats, eq(member.userId, userStats.userId)) .where(eq(member.organizationId, organizationId)) - let pooled = new Decimal(0) let lastPeriodCost = new Decimal(0) const memberIds: string[] = [] for (const row of rows) { memberIds.push(row.userId) - pooled = pooled.plus(toDecimal(row.currentPeriodCost)) lastPeriodCost = lastPeriodCost.plus(toDecimal(row.lastPeriodCost)) } return { memberIds, - currentPeriodCost: toNumber(pooled), lastPeriodCost: toNumber(lastPeriodCost), } } @@ -242,35 +236,14 @@ export async function getResolvedUserUsageData( interval: null, } - let currentUsageDecimal = toDecimal( - billingPeriod.source === 'reporting' ? 0 : stats.currentPeriodCost - ) - if (!orgScoped) { - currentUsageDecimal = currentUsageDecimal.plus( - await getBillingPeriodUsageCost( + let currentUsage = orgScoped + ? 0 + : await getBillingPeriodUsageCost( { type: 'user', id: userId }, billingPeriod, undefined, executor ) - ) - } - - // 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)) { - currentUsageDecimal = currentUsageDecimal.plus(snapshotUsageDecimal) - logger.info('Including Pro snapshot in usage display', { - userId, - currentPeriodCost: stats.currentPeriodCost, - proPeriodCostSnapshot: toNumber(snapshotUsageDecimal), - totalUsage: toNumber(currentUsageDecimal), - }) - } - } - let currentUsage = toNumber(currentUsageDecimal) let lastPeriodCost = toNumber(toDecimal(stats.lastPeriodCost)) let limit: number @@ -287,17 +260,15 @@ export async function getResolvedUserUsageData( ) limit = orgLimit.limit - const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId, executor) - orgMemberIds = pooled.memberIds - lastPeriodCost = pooled.lastPeriodCost - const ledgerUsage = await getBillingPeriodUsageCost( + const rollup = await getOrgMemberBillingRollup(subscription.referenceId, executor) + orgMemberIds = rollup.memberIds + lastPeriodCost = rollup.lastPeriodCost + currentUsage = await getBillingPeriodUsageCost( { type: 'organization', id: subscription.referenceId }, billingPeriod, undefined, executor ) - currentUsage = - (billingPeriod.source === 'reporting' ? 0 : pooled.currentPeriodCost) + ledgerUsage } else { limit = stats.currentUsageLimit ? toNumber(toDecimal(stats.currentUsageLimit)) @@ -709,46 +680,30 @@ export async function getEffectiveCurrentPeriodCost( let rawCost: number let refreshUserIds: string[] = [userId] + const billingPeriod = resolveSubscriptionUsagePeriod(subscription) ?? { + ...defaultBillingPeriod(), + source: 'default' as const, + anchorDate: null, + interval: null, + } + if (orgScoped && subscription) { - const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId, executor) - if (pooled.memberIds.length === 0) return 0 - refreshUserIds = pooled.memberIds - const billingPeriod = resolveSubscriptionUsagePeriod(subscription) ?? { - ...defaultBillingPeriod(), - source: 'default' as const, - anchorDate: null, - interval: null, - } - rawCost = - (billingPeriod.source === 'reporting' ? 0 : pooled.currentPeriodCost) + - (await getBillingPeriodUsageCost( - { type: 'organization', id: subscription.referenceId }, - billingPeriod, - undefined, - executor - )) + const rollup = await getOrgMemberBillingRollup(subscription.referenceId, executor) + if (rollup.memberIds.length === 0) return 0 + refreshUserIds = rollup.memberIds + rawCost = await getBillingPeriodUsageCost( + { type: 'organization', id: subscription.referenceId }, + billingPeriod, + undefined, + executor + ) } else { - const rows = await executor - .select({ current: userStats.currentPeriodCost }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - if (rows.length === 0) return 0 - const billingPeriod = resolveSubscriptionUsagePeriod(subscription) ?? { - ...defaultBillingPeriod(), - source: 'default' as const, - anchorDate: null, - interval: null, - } - rawCost = - (billingPeriod.source === 'reporting' ? 0 : toNumber(toDecimal(rows[0].current))) + - (await getBillingPeriodUsageCost( - { type: 'user', id: userId }, - billingPeriod, - undefined, - executor - )) + rawCost = await getBillingPeriodUsageCost( + { type: 'user', id: userId }, + billingPeriod, + undefined, + executor + ) } if (!subscription || !isPaid(subscription.plan) || !subscription.periodStart) { diff --git a/apps/sim/lib/billing/credits/daily-refresh.ts b/apps/sim/lib/billing/credits/daily-refresh.ts index 6370556a184..015a15f0dbe 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.ts @@ -8,7 +8,7 @@ * The total refresh consumed in a period is: * SUM( MIN(day_usage, daily_refresh_amount) ) for each day * - * This is subtracted from `currentPeriodCost` to derive "effective billable usage". + * This is subtracted from ledger period usage to derive "effective billable usage". */ import { db } from '@sim/db' diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts new file mode 100644 index 00000000000..f4938ca1079 --- /dev/null +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -0,0 +1,315 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockComputeOrgOverageAmount, + mockIsSubscriptionOrgScoped, + mockGetStampedPeriodRangeUsageCostByUser, + mockComputeDailyRefreshConsumed, + mockEnqueueOutboxEvent, + mockGetPlanPricing, + mockGetPlanTierDollars, + mockIsEnterprise, + mockIsFree, + mockRecordAudit, + mockCaptureServerEvent, +} = vi.hoisted(() => ({ + mockComputeOrgOverageAmount: vi.fn(), + mockIsSubscriptionOrgScoped: vi.fn(), + mockGetStampedPeriodRangeUsageCostByUser: vi.fn(), + mockComputeDailyRefreshConsumed: vi.fn(), + mockEnqueueOutboxEvent: vi.fn(), + mockGetPlanPricing: vi.fn(), + mockGetPlanTierDollars: vi.fn(), + mockIsEnterprise: vi.fn(), + mockIsFree: vi.fn(), + mockRecordAudit: vi.fn(), + mockCaptureServerEvent: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { OVERAGE_BILLED: 'overage.billed' }, + AuditResourceType: { BILLING: 'billing' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@/lib/billing/core/billing', () => ({ + computeOrgOverageAmount: mockComputeOrgOverageAmount, + isSubscriptionOrgScoped: mockIsSubscriptionOrgScoped, +})) + +vi.mock('@/lib/billing/core/reporting-period', () => ({ + ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY: 'reportingPeriodAnchorDate', +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + COPILOT_USAGE_SOURCES: ['copilot'], + getStampedPeriodRangeUsageCostByUser: mockGetStampedPeriodRangeUsageCostByUser, +})) + +vi.mock('@/lib/billing/credits/daily-refresh', () => ({ + computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed, +})) + +vi.mock('@/lib/billing/plan-helpers', () => ({ + getPlanTierDollars: mockGetPlanTierDollars, + isEnterprise: mockIsEnterprise, + isFree: mockIsFree, +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'], + getPlanPricing: mockGetPlanPricing, +})) + +vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({ + OUTBOX_EVENT_TYPES: { + STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice', + }, +})) + +vi.mock('@/lib/core/outbox/service', () => ({ + enqueueOutboxEvent: mockEnqueueOutboxEvent, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +import { closeElapsedBillingPeriod, sweepBillingCycleCloses } from '@/lib/billing/cycle-close' + +type SubInput = Parameters[0] + +const PERIOD_START = new Date('2026-08-01T00:00:00.000Z') +const PREV_PERIOD_START = new Date('2026-07-01T00:00:00.000Z') + +function subRow(overrides: Partial> = {}): SubInput { + return { + id: 'sub-1', + plan: 'team', + referenceId: 'org-1', + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_stripe_1', + status: 'active', + periodStart: PERIOD_START, + periodEnd: new Date('2026-09-01T00:00:00.000Z'), + billingInterval: 'month', + metadata: null, + lastClosedPeriodStart: PREV_PERIOD_START, + ...overrides, + } as SubInput +} + +/** + * Queues the org close's reads in table order: member roster, in-tx member + * userStats lock, organization credit row, subscription marker re-read, and + * the tracker userStats row. + */ +function queueOrgCloseReads({ + members = [{ userId: 'owner-1', role: 'owner' }], + orgRow = { creditBalance: '0' }, + markerRow = { lastClosedPeriodStart: PREV_PERIOD_START }, + trackerRow = { billedOverageThisPeriod: '0', creditBalance: '0' }, +} = {}) { + queueTableRows(schemaMock.member, members) + queueTableRows(schemaMock.userStats, []) + queueTableRows(schemaMock.organization, [orgRow]) + queueTableRows(schemaMock.subscription, [markerRow]) + queueTableRows(schemaMock.userStats, [trackerRow]) +} + +describe('closeElapsedBillingPeriod', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsSubscriptionOrgScoped.mockResolvedValue(true) + mockIsEnterprise.mockReturnValue(false) + mockIsFree.mockReturnValue(false) + mockGetPlanTierDollars.mockReturnValue(40) + mockGetPlanPricing.mockReturnValue({ basePrice: 40 }) + mockComputeDailyRefreshConsumed.mockResolvedValue(0) + mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 150]])) + mockComputeOrgOverageAmount.mockResolvedValue({ + effectiveUsage: 150, + baseSubscriptionAmount: 80, + dailyRefreshDeduction: 0, + totalOverage: 70, + }) + dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('initializes a null marker without billing', async () => { + const result = await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: null })) + + expect(result.status).toBe('initialized') + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled() + }) + + it('returns current when the marker already matches the period start', async () => { + const result = await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: PERIOD_START })) + + expect(result.status).toBe('current') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + + it('closes a team period: bills the remainder, resets trackers, and claims the marker', async () => { + queueOrgCloseReads() + + const result = await closeElapsedBillingPeriod(subRow()) + + expect(result.status).toBe('closed') + expect(result.overageBilled).toBe(70) + + expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith({ + plan: 'team', + seats: null, + periodStart: PREV_PERIOD_START, + periodEnd: PERIOD_START, + organizationId: 'org-1', + pooledLedgerUsage: 150, + memberIds: ['owner-1'], + }) + + expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1) + const [, eventType, payload] = mockEnqueueOutboxEvent.mock.calls[0] + expect(eventType).toBe('stripe.threshold-overage-invoice') + expect(payload).toMatchObject({ + customerId: 'cus_1', + stripeSubscriptionId: 'sub_stripe_1', + amountCents: 7000, + invoiceIdemKeyStem: `cycle-close-overage:sub-1:${PERIOD_START.toISOString()}:invoice`, + metadata: expect.objectContaining({ type: 'overage_billing', organizationId: 'org-1' }), + }) + + // Bookkeeping: last-period CASE write + billedOverage reset on member rows. + const bookkeepingSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).billedOverageThisPeriod === '0' + )?.[0] as Record + expect(bookkeepingSet).toBeDefined() + expect( + (bookkeepingSet.lastPeriodCost as { toSQL?: () => { sql: string } })?.toSQL?.().sql + ).toContain('CASE') + + // Marker claim committed in the same transaction. + const markerSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date + ) + expect(markerSet).toBeDefined() + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + expect(mockRecordAudit).toHaveBeenCalledTimes(1) + expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) + }) + + it('applies organization credits before invoicing and skips Stripe when covered', async () => { + queueOrgCloseReads({ orgRow: { creditBalance: '100' } }) + + const result = await closeElapsedBillingPeriod(subRow()) + + expect(result.status).toBe('closed') + expect(result.creditsApplied).toBe(70) + expect(result.overageBilled).toBe(0) + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + expect(mockRecordAudit).toHaveBeenCalledTimes(1) + }) + + it('subtracts overage already collected by threshold billing', async () => { + queueOrgCloseReads({ trackerRow: { billedOverageThisPeriod: '70', creditBalance: '0' } }) + + const result = await closeElapsedBillingPeriod(subRow()) + + expect(result.status).toBe('closed') + expect(result.overageBilled).toBe(0) + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('no-ops when a concurrent closer already advanced the marker', async () => { + queueOrgCloseReads({ markerRow: { lastClosedPeriodStart: PERIOD_START } }) + + const result = await closeElapsedBillingPeriod(subRow()) + + expect(result.status).toBe('already-closed') + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('books enterprise periods without collecting money', async () => { + mockIsEnterprise.mockReturnValue(true) + queueOrgCloseReads() + + const result = await closeElapsedBillingPeriod(subRow({ plan: 'enterprise' })) + + expect(result.status).toBe('closed') + expect(result.overageBilled).toBe(0) + expect(mockComputeOrgOverageAmount).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + // Bookkeeping still writes last-period sums. + const bookkeepingSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).billedOverageThisPeriod === '0' + ) + expect(bookkeepingSet).toBeDefined() + }) + + it('only advances the marker for enterprise orgs on reporting anchors', async () => { + mockIsEnterprise.mockReturnValue(true) + + const result = await closeElapsedBillingPeriod( + subRow({ plan: 'enterprise', metadata: { reportingPeriodAnchorDate: '2026-05-01' } }) + ) + + expect(result.status).toBe('closed') + expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + + it('closes a personal subscription against the user ledger', async () => { + mockIsSubscriptionOrgScoped.mockResolvedValue(false) + mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['user-1', 90]])) + // Personal reads: in-tx userStats lock, marker re-read, tracker row. + queueTableRows(schemaMock.userStats, []) + queueTableRows(schemaMock.subscription, [{ lastClosedPeriodStart: PREV_PERIOD_START }]) + queueTableRows(schemaMock.userStats, [{ billedOverageThisPeriod: '0', creditBalance: '0' }]) + + const result = await closeElapsedBillingPeriod( + subRow({ plan: 'pro', referenceId: 'user-1', stripeCustomerId: 'cus_user' }) + ) + + expect(result.status).toBe('closed') + // 90 ledger - 0 refresh - 40 base = 50 overage + expect(result.overageBilled).toBe(50) + expect(mockComputeOrgOverageAmount).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1) + }) +}) + +describe('sweepBillingCycleCloses', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsFree.mockReturnValue(false) + mockIsEnterprise.mockReturnValue(false) + }) + + it('initializes lagging markers and isolates per-subscription failures', async () => { + queueTableRows(schemaMock.subscription, [ + subRow({ id: 'sub-a', lastClosedPeriodStart: null }), + subRow({ id: 'sub-b', lastClosedPeriodStart: null, periodStart: null }), + ]) + + const summary = await sweepBillingCycleCloses() + + expect(summary.candidates).toBe(2) + expect(summary.initialized).toBe(1) + expect(summary.failed).toBe(0) + }) +}) diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts new file mode 100644 index 00000000000..79f06b72eed --- /dev/null +++ b/apps/sim/lib/billing/cycle-close.ts @@ -0,0 +1,553 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, organization, subscription as subscriptionTable, userStats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm' +import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants' +import { computeOrgOverageAmount, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' +import { ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY } from '@/lib/billing/core/reporting-period' +import { + COPILOT_USAGE_SOURCES, + getStampedPeriodRangeUsageCostByUser, +} from '@/lib/billing/core/usage-log' +import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' +import { getPlanTierDollars, isEnterprise, isFree } from '@/lib/billing/plan-helpers' +import { ENTITLED_SUBSCRIPTION_STATUSES, getPlanPricing } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import { captureServerEvent } from '@/lib/posthog/server' + +const logger = createLogger('BillingCycleClose') + +/** + * Minimum residual overage worth invoicing at cycle close, in dollars. + * Anything below this is forgiven rather than billed as a sub-cent invoice. + */ +const MIN_CLOSE_INVOICE_DOLLARS = 0.5 + +type SubscriptionRow = typeof subscriptionTable.$inferSelect + +export type CycleCloseStatus = 'initialized' | 'current' | 'closed' | 'already-closed' | 'skipped' + +export interface CycleCloseResult { + status: CycleCloseStatus + subscriptionId: string + overageBilled?: number + creditsApplied?: number +} + +/** + * Subtract one billing interval from a period boundary. Mirrors Stripe's + * anchor-day semantics closely enough for a close window: the ledger rows are + * matched by their write-time period stamps, so this bound only needs to + * enclose the closed period, not reproduce it exactly. + */ +function minusOneInterval(date: Date, billingInterval: string | null): Date { + const result = new Date(date.getTime()) + if (billingInterval === 'year') { + result.setUTCFullYear(result.getUTCFullYear() - 1) + } else { + result.setUTCMonth(result.getUTCMonth() - 1) + } + return result +} + +function hasEnterpriseReportingAnchor(sub: SubscriptionRow): boolean { + return ( + isEnterprise(sub.plan) && + isRecordLike(sub.metadata) && + typeof sub.metadata[ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY] === 'string' + ) +} + +/** + * Advance the durable close marker to `periodStart`, guarded so concurrent + * closers and replays collapse to one winner. Returns false when another + * worker already advanced the marker at or past this boundary. + */ +async function claimCloseMarker( + tx: Parameters[0]>[0], + subscriptionId: string, + periodStart: Date +): Promise { + const claimed = await tx + .update(subscriptionTable) + .set({ lastClosedPeriodStart: periodStart }) + .where( + and( + eq(subscriptionTable.id, subscriptionId), + or( + isNull(subscriptionTable.lastClosedPeriodStart), + lt(subscriptionTable.lastClosedPeriodStart, periodStart) + ) + ) + ) + .returning({ id: subscriptionTable.id }) + return claimed.length > 0 +} + +/** + * Close the most recently elapsed billing period for one subscription. + * + * Runs when the durable `lastClosedPeriodStart` marker lags the subscription's + * current `periodStart` (better-auth advances the row's period from Stripe's + * `customer.subscription.updated`). The close, per closed period: + * + * 1. Sums the closed period's ledger usage per member from write-time period + * stamps (`getStampedPeriodRangeUsageCostByUser`) — never `created_at`. + * 2. Collects final sub-threshold overage for non-enterprise plans: computed + * overage minus what threshold billing already collected + * (`billedOverageThisPeriod`), credits applied first, remainder invoiced + * through the transaction-enlisted Stripe outbox with deterministic + * idempotency stems keyed by `(subscriptionId, closed period)`. + * 3. Writes `lastPeriodCost` / `lastPeriodCopilotCost` bookkeeping from the + * same ledger sums and resets `billedOverageThisPeriod` for the new period. + * 4. Advances the marker in the same transaction, so the money, bookkeeping, + * and marker commit atomically — a crash retries the whole close, and the + * outbox's Stripe idempotency keys collapse invoice replays. + * + * Enterprise subscriptions never collect money here (billing is contractual, + * outside Stripe); they get bookkeeping + marker only, and orgs on reporting + * anchors skip bookkeeping too because their windows derive live from the + * anchor. A null marker initializes to the current `periodStart` without + * billing, so historical periods are never retroactively closed. + */ +export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise { + const base: CycleCloseResult = { status: 'skipped', subscriptionId: sub.id } + + if (!sub.periodStart || isFree(sub.plan)) return base + const periodStart = sub.periodStart + + if (sub.lastClosedPeriodStart && sub.lastClosedPeriodStart.getTime() >= periodStart.getTime()) { + return { ...base, status: 'current' } + } + + if (!sub.lastClosedPeriodStart) { + await db + .update(subscriptionTable) + .set({ lastClosedPeriodStart: periodStart }) + .where(and(eq(subscriptionTable.id, sub.id), isNull(subscriptionTable.lastClosedPeriodStart))) + logger.info('Initialized cycle-close marker without billing', { + subscriptionId: sub.id, + plan: sub.plan, + periodStart: periodStart.toISOString(), + }) + return { ...base, status: 'initialized' } + } + + const marker = sub.lastClosedPeriodStart + const expectedPrevStart = minusOneInterval(periodStart, sub.billingInterval) + // Money and bookkeeping cover exactly one period. A marker further back + // than one interval means missed sweeps; those older periods' sub-threshold + // tails are forgiven (loudly) rather than billed with multi-period math. + const closeFrom = marker.getTime() < expectedPrevStart.getTime() ? expectedPrevStart : marker + if (closeFrom.getTime() !== marker.getTime()) { + logger.error('Cycle close skipped elapsed periods; forgiving their residual overage', { + subscriptionId: sub.id, + plan: sub.plan, + marker: marker.toISOString(), + closingFrom: closeFrom.toISOString(), + periodStart: periodStart.toISOString(), + }) + } + if (closeFrom.getTime() >= periodStart.getTime()) { + // Degenerate window (clock skew or a shortened period) — just advance. + const advanced = await db.transaction(async (tx) => claimCloseMarker(tx, sub.id, periodStart)) + return { ...base, status: advanced ? 'closed' : 'already-closed' } + } + + const orgScoped = await isSubscriptionOrgScoped(sub) + const billingEntity = orgScoped + ? ({ type: 'organization', id: sub.referenceId } as const) + : ({ type: 'user', id: sub.referenceId } as const) + const closedRange = { from: closeFrom, to: periodStart } + + const enterprise = isEnterprise(sub.plan) + if (enterprise && hasEnterpriseReportingAnchor(sub)) { + // Reporting-anchor orgs derive every usage window live from the anchor; + // there is nothing to bill or book here. Advance the marker so the sweep + // stays quiet. + const advanced = await db.transaction(async (tx) => claimCloseMarker(tx, sub.id, periodStart)) + return { ...base, status: advanced ? 'closed' : 'already-closed' } + } + + const [usageByUser, copilotByUser] = await Promise.all([ + getStampedPeriodRangeUsageCostByUser(billingEntity, closedRange), + getStampedPeriodRangeUsageCostByUser(billingEntity, closedRange, COPILOT_USAGE_SOURCES), + ]) + let closedLedgerUsage = 0 + for (const cost of usageByUser.values()) closedLedgerUsage += cost + + const memberRows = orgScoped + ? await db + .select({ userId: member.userId, role: member.role }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + : [] + const memberIds = orgScoped ? memberRows.map((row) => row.userId) : [sub.referenceId] + const trackerUserId = orgScoped + ? (memberRows.find((row) => row.role === 'owner')?.userId ?? null) + : sub.referenceId + + // Final overage for the closed period (enterprise never bills overage). + let totalOverage = 0 + if (!enterprise) { + if (orgScoped) { + const { totalOverage: computed } = await computeOrgOverageAmount({ + plan: sub.plan, + seats: sub.seats ?? null, + periodStart: closeFrom, + periodEnd: periodStart, + organizationId: sub.referenceId, + pooledLedgerUsage: closedLedgerUsage, + memberIds, + }) + totalOverage = computed + } else { + const planDollars = getPlanTierDollars(sub.plan) + let refreshConsumed = 0 + if (planDollars > 0) { + refreshConsumed = await computeDailyRefreshConsumed({ + userIds: [sub.referenceId], + periodStart: closeFrom, + periodEnd: periodStart, + planDollars, + billingEntity, + }) + } + const { basePrice } = getPlanPricing(sub.plan) + totalOverage = Math.max(0, closedLedgerUsage - refreshConsumed - basePrice) + } + } + + const billingPeriodLabel = closeFrom.toISOString().slice(0, 7) + const collectMoney = + !enterprise && totalOverage > 0 && !!sub.stripeCustomerId && !!sub.stripeSubscriptionId + + const closeResult = await db.transaction( + async ( + tx + ): Promise<{ status: 'closed' | 'already-closed'; billed: number; creditsApplied: number }> => { + await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) + + // Canonical lock order: member userStats rows, then the organization row. + if (memberIds.length > 0) { + await tx + .select({ userId: userStats.userId }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + .for('update') + } + let orgCreditBalance = 0 + if (orgScoped) { + const [orgRow] = await tx + .select({ creditBalance: organization.creditBalance }) + .from(organization) + .where(eq(organization.id, sub.referenceId)) + .for('update') + .limit(1) + orgCreditBalance = toNumber(toDecimal(orgRow?.creditBalance)) + } + + // Re-check the marker under the locks: a concurrent closer that already + // committed makes this a no-op (its billedOverage reset must not be + // mistaken for unbilled overage). + const [current] = await tx + .select({ lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart }) + .from(subscriptionTable) + .where(eq(subscriptionTable.id, sub.id)) + .limit(1) + if ( + current?.lastClosedPeriodStart && + current.lastClosedPeriodStart.getTime() >= periodStart.getTime() + ) { + return { status: 'already-closed', billed: 0, creditsApplied: 0 } + } + + let billed = 0 + let creditsApplied = 0 + + if (collectMoney && trackerUserId) { + const [tracker] = await tx + .select({ + billedOverageThisPeriod: userStats.billedOverageThisPeriod, + creditBalance: userStats.creditBalance, + }) + .from(userStats) + .where(eq(userStats.userId, trackerUserId)) + .limit(1) + + const alreadyBilled = toNumber(toDecimal(tracker?.billedOverageThisPeriod)) + let remaining = Math.max(0, totalOverage - alreadyBilled) + + if (remaining > 0) { + const creditBalance = orgScoped + ? orgCreditBalance + : toNumber(toDecimal(tracker?.creditBalance)) + if (creditBalance > 0) { + creditsApplied = Math.min(creditBalance, remaining) + if (orgScoped) { + await tx + .update(organization) + .set({ + creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${creditsApplied})`, + }) + .where(eq(organization.id, sub.referenceId)) + } else { + await tx + .update(userStats) + .set({ + creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${creditsApplied})`, + }) + .where(eq(userStats.userId, trackerUserId)) + } + remaining -= creditsApplied + } + + if (remaining >= MIN_CLOSE_INVOICE_DOLLARS) { + const amountCents = Math.round(remaining * 100) + const idemStem = `cycle-close-overage:${sub.id}:${periodStart.toISOString()}` + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, { + customerId: sub.stripeCustomerId, + stripeSubscriptionId: sub.stripeSubscriptionId, + amountCents, + description: `Final overage billing – ${billingPeriodLabel}`, + itemDescription: `Usage overage ($${remaining.toFixed(2)})`, + billingPeriod: billingPeriodLabel, + invoiceIdemKeyStem: `${idemStem}:invoice`, + itemIdemKeyStem: `${idemStem}:item`, + metadata: { + type: 'overage_billing', + subscriptionId: sub.stripeSubscriptionId ?? '', + billingPeriod: billingPeriodLabel, + ...(orgScoped ? { organizationId: sub.referenceId } : { userId: sub.referenceId }), + }, + }) + billed = remaining + } else if (remaining > 0) { + logger.info('Forgiving sub-minimum cycle-close overage', { + subscriptionId: sub.id, + remaining, + }) + } + } + } + + // Bookkeeping: previous-period totals from the same stamped ledger sums. + if (memberIds.length > 0) { + const lastCostCases = sql.join( + memberIds.map( + (userId) => sql`WHEN ${userId} THEN ${(usageByUser.get(userId) ?? 0).toString()}` + ), + sql` ` + ) + const lastCopilotCases = sql.join( + memberIds.map( + (userId) => sql`WHEN ${userId} THEN ${(copilotByUser.get(userId) ?? 0).toString()}` + ), + sql` ` + ) + await tx + .update(userStats) + .set({ + lastPeriodCost: sql`CASE ${userStats.userId} ${lastCostCases} ELSE ${userStats.lastPeriodCost} END`, + lastPeriodCopilotCost: sql`CASE ${userStats.userId} ${lastCopilotCases} ELSE ${userStats.lastPeriodCopilotCost} END`, + billedOverageThisPeriod: '0', + }) + .where(inArray(userStats.userId, memberIds)) + } + if (orgScoped) { + await tx + .update(organization) + .set({ departedMemberUsage: '0' }) + .where(eq(organization.id, sub.referenceId)) + } + + const advanced = await claimCloseMarker(tx, sub.id, periodStart) + if (!advanced) { + throw new Error( + `Cycle-close marker for subscription ${sub.id} advanced concurrently; rolling back` + ) + } + + return { status: 'closed', billed, creditsApplied } + } + ) + + if (closeResult.status === 'already-closed') { + return { ...base, status: 'already-closed' } + } + + logger.info('Closed billing period', { + subscriptionId: sub.id, + plan: sub.plan, + orgScoped, + closedFrom: closeFrom.toISOString(), + closedTo: periodStart.toISOString(), + closedLedgerUsage, + totalOverage, + overageBilled: closeResult.billed, + creditsApplied: closeResult.creditsApplied, + }) + + if (closeResult.billed > 0 || closeResult.creditsApplied > 0) { + const actorId = trackerUserId ?? sub.referenceId + const settledVia = closeResult.billed > 0 ? 'stripe' : 'credits' + recordAudit({ + actorId, + action: AuditAction.OVERAGE_BILLED, + resourceType: AuditResourceType.BILLING, + resourceId: sub.id, + description: `Final overage of $${(closeResult.billed + closeResult.creditsApplied).toFixed(2)} settled at cycle close for ${sub.referenceId}`, + metadata: { + entityType: billingEntity.type, + referenceId: sub.referenceId, + ...(orgScoped ? { organizationId: sub.referenceId } : {}), + plan: sub.plan, + amount: closeResult.billed + closeResult.creditsApplied, + currency: 'usd', + creditsApplied: closeResult.creditsApplied, + settledVia, + billingPeriod: billingPeriodLabel, + }, + }) + captureServerEvent(actorId, 'overage_billed', { + amount: closeResult.billed + closeResult.creditsApplied, + currency: 'usd', + entity_type: billingEntity.type, + reference_id: sub.referenceId, + settled_via: settledVia, + }) + } + + return { + ...base, + status: 'closed', + overageBilled: closeResult.billed, + creditsApplied: closeResult.creditsApplied, + } +} + +/** + * Terminal bookkeeping for a subscription that is ending (deleted/cancelled): + * writes `lastPeriodCost` / `lastPeriodCopilotCost` from the final period's + * stamped ledger sums and clears the per-period trackers so a future + * subscription starts clean. Money is NOT collected here — the deletion + * handler bills the final overage itself before calling this. + */ +export async function writeFinalPeriodBookkeeping(sub: { + plan: string | null + referenceId: string + periodStart?: Date | null + periodEnd?: Date | null +}): Promise { + if (!sub.periodStart) return + const orgScoped = await isSubscriptionOrgScoped(sub) + const billingEntity = orgScoped + ? ({ type: 'organization', id: sub.referenceId } as const) + : ({ type: 'user', id: sub.referenceId } as const) + const range = { from: sub.periodStart, to: sub.periodEnd ?? new Date() } + + const [usageByUser, copilotByUser] = await Promise.all([ + getStampedPeriodRangeUsageCostByUser(billingEntity, range), + getStampedPeriodRangeUsageCostByUser(billingEntity, range, COPILOT_USAGE_SOURCES), + ]) + + const memberIds = orgScoped + ? ( + await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + ).map((row) => row.userId) + : [sub.referenceId] + + await db.transaction(async (tx) => { + await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) + if (memberIds.length > 0) { + const lastCostCases = sql.join( + memberIds.map( + (userId) => sql`WHEN ${userId} THEN ${(usageByUser.get(userId) ?? 0).toString()}` + ), + sql` ` + ) + const lastCopilotCases = sql.join( + memberIds.map( + (userId) => sql`WHEN ${userId} THEN ${(copilotByUser.get(userId) ?? 0).toString()}` + ), + sql` ` + ) + await tx + .update(userStats) + .set({ + lastPeriodCost: sql`CASE ${userStats.userId} ${lastCostCases} ELSE ${userStats.lastPeriodCost} END`, + lastPeriodCopilotCost: sql`CASE ${userStats.userId} ${lastCopilotCases} ELSE ${userStats.lastPeriodCopilotCost} END`, + billedOverageThisPeriod: '0', + }) + .where(inArray(userStats.userId, memberIds)) + } + if (orgScoped) { + await tx + .update(organization) + .set({ departedMemberUsage: '0' }) + .where(eq(organization.id, sub.referenceId)) + } + }) +} + +export interface CycleCloseSweepSummary { + candidates: number + closed: number + initialized: number + failed: number +} + +/** + * Daily catch-all that closes every elapsed billing period. Candidates are + * entitled subscriptions whose close marker lags their current `periodStart` + * — i.e. the period advanced (via Stripe sync) since the last close. Each + * close is independently atomic, so one failure never blocks the rest. + */ +export async function sweepBillingCycleCloses(): Promise { + const candidates = await db + .select() + .from(subscriptionTable) + .where( + and( + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES), + sql`${subscriptionTable.periodStart} IS NOT NULL`, + or( + isNull(subscriptionTable.lastClosedPeriodStart), + lt(subscriptionTable.lastClosedPeriodStart, subscriptionTable.periodStart) + ) + ) + ) + + const summary: CycleCloseSweepSummary = { + candidates: candidates.length, + closed: 0, + initialized: 0, + failed: 0, + } + + for (const sub of candidates) { + try { + const result = await closeElapsedBillingPeriod(sub) + if (result.status === 'closed') summary.closed++ + if (result.status === 'initialized') summary.initialized++ + } catch (error) { + summary.failed++ + logger.error('Cycle close failed for subscription', { + subscriptionId: sub.id, + plan: sub.plan, + error: getErrorMessage(error), + }) + } + } + + logger.info('Billing cycle-close sweep finished', { ...summary }) + return summary +} diff --git a/apps/sim/lib/billing/organizations/lock-order.test.ts b/apps/sim/lib/billing/organizations/lock-order.test.ts index fb1d96893ae..1f25db893e7 100644 --- a/apps/sim/lib/billing/organizations/lock-order.test.ts +++ b/apps/sim/lib/billing/organizations/lock-order.test.ts @@ -111,17 +111,21 @@ describe('paid-org join billing lock ordering', () => { mockChangeWorkspaceStoragePayersInTx.mockReset() }) - it('locks the personal subscription before mutating userStats', async () => { + it('locks the personal subscription before pausing it and never mutates userStats', async () => { const { tx, ops } = createRecordingTx() await reapplyPaidOrgJoinBillingForExistingMemberTx(tx as DbOrTx, 'user-1', 'org-1') - const firstUserStatsUpdate = ops.findIndex((o) => o.op === 'update' && o.table === userStats) + const userStatsUpdate = ops.findIndex((o) => o.op === 'update' && o.table === userStats) const subscriptionLock = ops.findIndex((o) => o.op === 'lock' && o.table === subscriptionTable) + const subscriptionUpdate = ops.findIndex( + (o) => o.op === 'update' && o.table === subscriptionTable + ) - expect(firstUserStatsUpdate).toBeGreaterThanOrEqual(0) + // Ledger entity stamps attribute usage; join billing no longer touches userStats. + expect(userStatsUpdate).toBe(-1) expect(subscriptionLock).toBeGreaterThanOrEqual(0) - expect(subscriptionLock).toBeLessThan(firstUserStatsUpdate) + expect(subscriptionUpdate).toBeGreaterThan(subscriptionLock) }) it('still locks an already-paused personal Pro so a concurrent restore cannot pass it', async () => { @@ -276,8 +280,9 @@ describe('workspace payer-change transaction lock ordering', () => { ) const payerTransfer = ops.findIndex((entry) => entry.op === 'payer-transfer') expect(workspaceLock).toBeGreaterThanOrEqual(0) - expect(userStatsUpdate).toBeGreaterThan(workspaceLock) - expect(payerTransfer).toBeGreaterThan(userStatsUpdate) + // Ledger entity stamps attribute usage; join billing no longer touches userStats. + expect(userStatsUpdate).toBe(-1) + expect(payerTransfer).toBeGreaterThan(workspaceLock) }) }) diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index bbc479963fd..e25c96c8822 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -206,20 +206,14 @@ export interface RestoreProResult { /** * Restore a user's personal Pro subscription if it was paused - * (`cancelAtPeriodEnd = true`) and merge any snapshotted Pro usage back - * into their current-period usage. + * (`cancelAtPeriodEnd = true`). No usage moves — ledger entity stamps kept + * their personal usage attributed to them throughout the org membership. * - * All DB mutations run inside a single transaction so partial progress - * cannot be committed: either both the subscription un-pause and the - * usage snapshot merge succeed, or neither does. Errors propagate to - * the caller so webhook handlers can rely on Stripe retry semantics. + * Errors propagate to the caller so webhook handlers can rely on Stripe + * retry semantics. * - * Idempotent: - * - Early returns when the user has no paused Pro subscription, so - * re-runs after a successful restore are no-ops. - * - The snapshot merge only runs when `proPeriodCostSnapshot > 0`, - * so a second call after a prior success (which zeroes the - * snapshot) does nothing. + * Idempotent: early returns when the user has no paused Pro subscription, + * so re-runs after a successful restore are no-ops. * * Called when: * - A member leaves a team (via `removeUserFromOrganization`). @@ -287,46 +281,6 @@ export async function restoreUserProSubscription(userId: string): Promise 0) { - await tx - .update(organization) - .set({ - departedMemberUsage: sql`${organization.departedMemberUsage} + ${usageCaptured}`, - }) - .where(eq(organization.id, params.sourceOrganizationId)) - await tx - .update(userStats) - .set({ currentPeriodCost: '0' }) - .where(eq(userStats.userId, params.userId)) - } - const added = await ensureUserInOrganizationTx(tx, { userId: params.userId, organizationId: params.destinationOrganizationId, @@ -1279,7 +1185,9 @@ export async function transferUserBetweenOrganizations( workspaceAccessRevoked, credentialMembershipsRevoked, pendingInvitationsCancelled: cancelledInvitations.length, - usageCaptured, + // Nothing to capture: the member's ledger rows stay stamped to the + // source org's period and are billed at its cycle close. + usageCaptured: 0, } } ) @@ -1299,10 +1207,11 @@ export async function transferUserBetweenOrganizations( * * Handles: * - Owner removal prevention - * - Departed member usage capture * - Member record deletion * - Pro subscription restoration when leaving a paid team - * - Pro usage restoration from snapshot + * + * No usage moves on departure: the member's ledger rows stay stamped to the + * org's billing period and are billed at its cycle close. * * Note: Users can only belong to one organization at a time. */ @@ -1397,34 +1306,6 @@ export async function removeUserFromOrganization( .returning({ id: invitation.id }) : [] - const captureDepartedUsage = async () => { - if (skipBillingLogic) return 0 - - const [departingUserStats] = await tx - .select({ currentPeriodCost: userStats.currentPeriodCost }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .for('update') - .limit(1) - - const usage = toNumber(toDecimal(departingUserStats?.currentPeriodCost)) - if (usage <= 0) return 0 - - await tx - .update(organization) - .set({ - departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`, - }) - .where(eq(organization.id, organizationId)) - - await tx - .update(userStats) - .set({ currentPeriodCost: '0' }) - .where(eq(userStats.userId, userId)) - - return usage - } - // Permission groups are organization-scoped, so a departing member's group // membership must be cleared whenever they leave the org — including the // zero-workspace early return below (a group can exist with members but no @@ -1439,12 +1320,12 @@ export async function removeUserFromOrganization( ) if (workspaceIds.length === 0) { - const capturedUsage = await captureDepartedUsage() - return { skipped: false as const, workspaceIdsToRevoke: [] as string[], - usageCaptured: capturedUsage, + // Nothing to capture: the member's ledger rows stay stamped to + // this org's period and are billed at its cycle close. + usageCaptured: 0, credentialMembershipsRevoked: 0, pendingInvitationsCancelled: cancelledInvitations.length, } @@ -1484,12 +1365,11 @@ export async function removeUserFromOrganization( userId ) await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, userId) - const capturedUsage = await captureDepartedUsage() return { skipped: false as const, workspaceIdsToRevoke: deletedPerms.map((row) => row.entityId), - usageCaptured: capturedUsage, + usageCaptured: 0, credentialMembershipsRevoked, pendingInvitationsCancelled: cancelledInvitations.length, } @@ -1513,14 +1393,6 @@ export async function removeUserFromOrganization( // resolving to this org immediately, not after the membership-cache TTL. invalidateMembershipCache(userId) - if (result.usageCaptured > 0) { - logger.info('Captured departed member usage', { - organizationId, - userId, - usage: result.usageCaptured, - }) - } - logger.info('Removed member from organization', { organizationId, userId, diff --git a/apps/sim/lib/billing/threshold-billing.test.ts b/apps/sim/lib/billing/threshold-billing.test.ts index c984f0c8038..7f975065177 100644 --- a/apps/sim/lib/billing/threshold-billing.test.ts +++ b/apps/sim/lib/billing/threshold-billing.test.ts @@ -107,29 +107,14 @@ const expectedBillingPeriod = { end: new Date('2026-06-01T00:00:00.000Z'), } -const defaultUsageSnapshotRow = { - currentPeriodCost: '0', - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null as Date | null, - lastPeriodCost: '0', -} - -/** - * Queues the two pre-transaction personal reads: the user_stats usage snapshot - * and the subscription's Stripe customer row. - */ -function queuePersonalReads( - snapshot: Record = defaultUsageSnapshotRow, - customerId = 'cus_1' -) { - queueTableRows(schemaMock.userStats, [snapshot]) +/** Queues the pre-transaction personal read: the subscription's Stripe customer row. */ +function queuePersonalReads(customerId = 'cus_1') { queueTableRows(schemaMock.subscription, [{ stripeCustomerId: customerId }]) } /** Builds the locked in-transaction user_stats row. */ function lockedStatsRow(overrides: Record = {}) { return { - ...defaultUsageSnapshotRow, billedOverageThisPeriod: '0', creditBalance: '0', ...overrides, @@ -144,20 +129,18 @@ function queueLockedStats(row: Record) { const orgMemberUsageRow = { userId: 'owner-1', role: 'owner', - currentPeriodCost: '350', - departedMemberUsage: '25', } /** * Queues the organization settlement reads in table order: the pre-transaction - * member usage join, then the locked owner row, owner stats, organization row, - * and locked member usage join inside the transaction. + * member join, then the locked owner row, owner stats, organization row, and + * locked member join inside the transaction. */ function queueOrgReads({ memberUsageRows = [orgMemberUsageRow], lockedOwnerRows = [{ userId: 'owner-1' }], ownerStatsRows = [{ billedOverageThisPeriod: '0' }], - organizationRows = [{ creditBalance: '0', departedMemberUsage: '25' }], + organizationRows = [{ creditBalance: '0' }], lockedMemberUsageRows = memberUsageRows, }: { memberUsageRows?: unknown[] @@ -202,7 +185,6 @@ describe('checkAndBillOverageThreshold', () => { }) it('does not lock user_stats when calculated overage is below threshold', async () => { - queuePersonalReads() mockCalculateSubscriptionOverage.mockResolvedValue(99) await checkAndBillOverageThreshold('user-1') @@ -216,7 +198,7 @@ describe('checkAndBillOverageThreshold', () => { periodEnd: userSubscription.periodEnd, }) expect(dbChainMockFns.transaction).not.toHaveBeenCalled() - expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) @@ -540,34 +522,6 @@ describe('checkAndBillOverageThreshold', () => { expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) - it('skips personal threshold billing when locked usage inputs changed', async () => { - queuePersonalReads({ ...defaultUsageSnapshotRow, currentPeriodCost: '250' }) - queueLockedStats(lockedStatsRow({ lastPeriodCost: '250' })) - mockCalculateSubscriptionOverage.mockResolvedValue(250) - - await checkAndBillOverageThreshold('user-1') - - expect(dbChainMockFns.transaction).toHaveBeenCalled() - expect(dbChainMockFns.update).not.toHaveBeenCalled() - expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() - }) - - it('throws retryably in markerless strict mode when locked personal usage changes', async () => { - queuePersonalReads({ ...defaultUsageSnapshotRow, currentPeriodCost: '250' }) - queueLockedStats(lockedStatsRow({ lastPeriodCost: '250' })) - mockCalculateSubscriptionOverage.mockResolvedValue(250) - - await expect( - checkAndBillOverageThreshold('user-1', undefined, { onError: 'throw' }) - ).rejects.toMatchObject({ - name: ThresholdSettlementError.name, - code: 'concurrent_state_change', - retryable: true, - }) - expect(dbChainMockFns.update).not.toHaveBeenCalled() - expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() - }) - it('wraps lock timeouts in markerless strict mode', async () => { queuePersonalReads() mockCalculateSubscriptionOverage.mockResolvedValue(250) @@ -588,6 +542,7 @@ describe('checkAndBillOverageThreshold', () => { mockIsOrgScopedSubscription.mockReturnValue(true) mockIsOrganizationBillingBlocked.mockResolvedValue(false) mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) + mockGetBillingPeriodUsageCost.mockResolvedValue(350) queueOrgReads() mockComputeOrgOverageAmount.mockResolvedValue({ totalOverage: 250, @@ -603,8 +558,7 @@ describe('checkAndBillOverageThreshold', () => { periodStart: new Date('2026-05-01T00:00:00.000Z'), periodEnd: new Date('2026-06-01T00:00:00.000Z'), organizationId: userSubscription.referenceId, - pooledCurrentPeriodCost: 350, - departedMemberUsage: 25, + pooledLedgerUsage: 350, memberIds: ['owner-1'], }) expect(dbChainMockFns.transaction).toHaveBeenCalled() @@ -615,27 +569,6 @@ describe('checkAndBillOverageThreshold', () => { expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1) }) - it('skips stale organization overage when locked usage inputs changed', async () => { - mockIsOrgScopedSubscription.mockReturnValue(true) - mockIsOrganizationBillingBlocked.mockResolvedValue(false) - mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) - queueOrgReads({ - organizationRows: [{ creditBalance: '0', departedMemberUsage: '75' }], - lockedMemberUsageRows: [{ ...orgMemberUsageRow, departedMemberUsage: '75' }], - }) - mockComputeOrgOverageAmount.mockResolvedValue({ - totalOverage: 250, - baseSubscriptionAmount: 100, - effectiveUsage: 350, - }) - - await checkAndBillOverageThreshold('user-1') - - expect(dbChainMockFns.transaction).toHaveBeenCalled() - expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() - expect(dbChainMockFns.update).not.toHaveBeenCalled() - }) - it('rechecks organization billed overage on the locked owner tracker', async () => { mockIsOrgScopedSubscription.mockReturnValue(true) mockIsOrganizationBillingBlocked.mockResolvedValue(false) @@ -659,29 +592,11 @@ describe('checkAndBillOverageThreshold', () => { mockIsOrganizationBillingBlocked.mockResolvedValue(false) mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) queueOrgReads({ - memberUsageRows: [ - orgMemberUsageRow, - { - userId: 'member-1', - role: 'member', - currentPeriodCost: '25', - departedMemberUsage: '25', - }, - ], + memberUsageRows: [orgMemberUsageRow, { userId: 'member-1', role: 'member' }], lockedOwnerRows: [{ userId: 'member-1' }], lockedMemberUsageRows: [ - { - userId: 'owner-1', - role: 'member', - currentPeriodCost: '350', - departedMemberUsage: '25', - }, - { - userId: 'member-1', - role: 'owner', - currentPeriodCost: '25', - departedMemberUsage: '25', - }, + { userId: 'owner-1', role: 'member' }, + { userId: 'member-1', role: 'owner' }, ], }) mockComputeOrgOverageAmount.mockResolvedValue({ diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index f99f821a110..f16018688fe 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -30,19 +30,10 @@ const logger = createLogger('ThresholdBilling') const OVERAGE_THRESHOLD = envNumber(env.OVERAGE_THRESHOLD_DOLLARS, DEFAULT_OVERAGE_THRESHOLD) const USAGE_TOTAL_EPSILON = 0.000001 -interface PersonalUsageSnapshot { - currentPeriodCost: number - proPeriodCostSnapshot: number - proPeriodCostSnapshotAt: Date | null - lastPeriodCost: number -} - interface OrganizationUsageSnapshot { memberIds: string[] ownerId: string memberSignature: string - pooledCurrentPeriodCost: number - departedMemberUsage: number } interface ThresholdBillingPeriod { @@ -288,12 +279,6 @@ export async function checkAndBillOverageThreshold( return checkAndBillOrganizationOverageThreshold(userSubscription.referenceId, options) } - const usageSnapshot = await getPersonalUsageSnapshot(userId) - if (!usageSnapshot) { - logger.warn('User stats not found for threshold billing', { userId }) - return requireSettlementStateOutcome(options, 'User stats are required for settlement') - } - const currentOverage = await calculateSubscriptionOverage({ id: userSubscription.id, plan: userSubscription.plan, @@ -359,19 +344,6 @@ export async function checkAndBillOverageThreshold( } const stats = statsRecords[0] - const lockedUsageSnapshot = personalUsageSnapshotFromStats(stats) - if (!personalUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)) { - logger.debug('Personal usage changed during threshold billing check; retry later', { - userId, - usageSnapshot, - lockedUsageSnapshot, - }) - return retryConcurrentSettlement( - options, - 'Personal usage changed during threshold settlement' - ) - } - const billedOverageThisPeriod = toNumber(toDecimal(stats.billedOverageThisPeriod)) const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod) @@ -569,12 +541,8 @@ async function checkAndBillOrganizationOverageThreshold( .select({ userId: member.userId, role: member.role, - currentPeriodCost: userStats.currentPeriodCost, - departedMemberUsage: organization.departedMemberUsage, }) .from(member) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .innerJoin(organization, eq(organization.id, member.organizationId)) .where(eq(member.organizationId, organizationId)) logger.debug('Found organization members', { @@ -623,16 +591,13 @@ async function checkAndBillOrganizationOverageThreshold( periodStart: orgSubscription.periodStart ?? null, periodEnd: orgSubscription.periodEnd ?? null, organizationId, - pooledCurrentPeriodCost: usageSnapshot.pooledCurrentPeriodCost + ledgerUsage, - departedMemberUsage: usageSnapshot.departedMemberUsage, + pooledLedgerUsage: ledgerUsage, memberIds: usageSnapshot.memberIds, }) if (currentOverage < threshold) { logger.debug('Organization threshold billing check below threshold before locking', { organizationId, - totalTeamUsage: - usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage, ledgerUsage, effectiveTeamUsage, basePrice, @@ -718,12 +683,8 @@ async function checkAndBillOrganizationOverageThreshold( .select({ userId: member.userId, role: member.role, - currentPeriodCost: userStats.currentPeriodCost, - departedMemberUsage: organization.departedMemberUsage, }) .from(member) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .innerJoin(organization, eq(organization.id, member.organizationId)) .where(eq(member.organizationId, organizationId)) const lockedUsageSnapshot = buildOrganizationUsageSnapshot(lockedMemberUsageRows) @@ -732,15 +693,18 @@ async function checkAndBillOrganizationOverageThreshold( lockedOwnerId !== usageSnapshot.ownerId || !organizationUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot) ) { - logger.debug('Organization usage changed during threshold billing check; retry later', { - organizationId, - usageSnapshot, - lockedUsageSnapshot, - lockedOwnerId, - }) + logger.debug( + 'Organization membership changed during threshold billing check; retry later', + { + organizationId, + usageSnapshot, + lockedUsageSnapshot, + lockedOwnerId, + } + ) return retryConcurrentSettlement( options, - 'Organization usage changed during threshold settlement' + 'Organization membership changed during threshold settlement' ) } @@ -751,8 +715,6 @@ async function checkAndBillOrganizationOverageThreshold( logger.debug('Organization threshold billing check', { organizationId, - totalTeamUsage: - usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage, ledgerUsage, effectiveTeamUsage, basePrice, @@ -911,77 +873,21 @@ async function checkAndBillOrganizationOverageThreshold( } } -async function getPersonalUsageSnapshot(userId: string): Promise { - const [stats] = await db - .select({ - currentPeriodCost: userStats.currentPeriodCost, - proPeriodCostSnapshot: userStats.proPeriodCostSnapshot, - proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt, - lastPeriodCost: userStats.lastPeriodCost, - }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - return stats ? personalUsageSnapshotFromStats(stats) : null -} - -function personalUsageSnapshotFromStats(stats: { - currentPeriodCost: string | number | null - proPeriodCostSnapshot: string | number | null - proPeriodCostSnapshotAt: Date | null - lastPeriodCost: string | number | null -}): PersonalUsageSnapshot { - return { - currentPeriodCost: toNumber(toDecimal(stats.currentPeriodCost)), - proPeriodCostSnapshot: toNumber(toDecimal(stats.proPeriodCostSnapshot)), - proPeriodCostSnapshotAt: stats.proPeriodCostSnapshotAt, - lastPeriodCost: toNumber(toDecimal(stats.lastPeriodCost)), - } -} - -function personalUsageSnapshotMatches( - expected: PersonalUsageSnapshot, - actual: PersonalUsageSnapshot -): boolean { - return ( - Math.abs(expected.currentPeriodCost - actual.currentPeriodCost) <= USAGE_TOTAL_EPSILON && - Math.abs(expected.proPeriodCostSnapshot - actual.proPeriodCostSnapshot) <= - USAGE_TOTAL_EPSILON && - Math.abs(expected.lastPeriodCost - actual.lastPeriodCost) <= USAGE_TOTAL_EPSILON && - nullableDateTime(expected.proPeriodCostSnapshotAt) === - nullableDateTime(actual.proPeriodCostSnapshotAt) - ) -} - function buildOrganizationUsageSnapshot( rows: { userId: string role: string - currentPeriodCost: string | number | null - departedMemberUsage: string | number | null }[] ): OrganizationUsageSnapshot | null { const owner = rows.find((row) => row.role === 'owner') if (!owner) return null const sortedRows = [...rows].sort((a, b) => a.userId.localeCompare(b.userId)) - let pooledCurrentPeriodCost = 0 - for (const row of sortedRows) { - pooledCurrentPeriodCost += toNumber(toDecimal(row.currentPeriodCost)) - } return { memberIds: sortedRows.map((row) => row.userId), ownerId: owner.userId, - memberSignature: sortedRows - .map( - (row) => - `${row.userId}:${row.role}:${toNumber(toDecimal(row.currentPeriodCost)).toFixed(6)}` - ) - .join('|'), - pooledCurrentPeriodCost, - departedMemberUsage: toNumber(toDecimal(owner.departedMemberUsage)), + memberSignature: sortedRows.map((row) => `${row.userId}:${row.role}`).join('|'), } } @@ -989,13 +895,5 @@ function organizationUsageSnapshotMatches( expected: OrganizationUsageSnapshot, actual: OrganizationUsageSnapshot ): boolean { - return ( - expected.ownerId === actual.ownerId && - expected.memberSignature === actual.memberSignature && - Math.abs(expected.departedMemberUsage - actual.departedMemberUsage) <= USAGE_TOTAL_EPSILON - ) -} - -function nullableDateTime(value: Date | null): number | null { - return value?.getTime() ?? null + return expected.ownerId === actual.ownerId && expected.memberSignature === actual.memberSignature } diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index 2a117da98a1..cdf4d961e77 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -96,7 +96,6 @@ vi.mock('@/lib/messaging/email/validation', () => ({ import { handleInvoicePaymentFailed, handleInvoicePaymentSucceeded, - resetUsageForSubscription, } from '@/lib/billing/webhooks/invoices' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -263,51 +262,4 @@ describe('invoice billing recovery', () => { expect(mockUnblockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed') expect(mockBlockOrgMembers).not.toHaveBeenCalled() }) - - it('locks member userStats before the organization row during usage reset', async () => { - queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // owner member row - queueSelectResponse({ limitResult: [{ userId: 'owner-1' }] }) // owner userStats - queueSelectResponse({ whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }] }) // member ids - queueSelectResponse({ whereResult: [] }) // all-member userStats FOR UPDATE (pre-org lock) - queueSelectResponse({ limitResult: [{ id: 'org-1' }] }) // organization - queueSelectResponse({ - whereResult: [ - { userId: 'owner-1', current: '125', currentCopilot: '10' }, - { userId: 'member-1', current: '75', currentCopilot: '5' }, - ], - }) - queueSelectResponse({ whereResult: [] }) - queueSelectResponse({ whereResult: [] }) - - await resetUsageForSubscription({ plan: 'team', referenceId: 'org-1' }) - - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.update).toHaveBeenCalledTimes(2) - - const whereArgs = dbChainMockFns.where.mock.calls.map( - (call) => call[0] as { type?: string; column?: string; left?: string } - ) - const allMemberStatsLockIndex = whereArgs.findIndex( - (arg) => arg?.type === 'inArray' && arg?.column === 'userStats.userId' - ) - const orgLockIndex = whereArgs.findIndex( - (arg) => arg?.type === 'eq' && arg?.left === 'organization.id' - ) - expect(allMemberStatsLockIndex).toBeGreaterThanOrEqual(0) - expect(orgLockIndex).toBeGreaterThanOrEqual(0) - expect(allMemberStatsLockIndex).toBeLessThan(orgLockIndex) - - const statsReset = dbChainMockFns.set.mock.calls[0][0] as Record - expect(statsReset.currentPeriodCost).not.toBe('0') - expect(statsReset.currentPeriodCopilotCost).not.toBe('0') - expect(statsReset.lastPeriodCost).toMatchObject({ - toSQL: expect.any(Function), - }) - expect((statsReset.lastPeriodCost as { toSQL: () => { sql: string } }).toSQL().sql).toContain( - 'CASE' - ) - expect( - (statsReset.currentPeriodCost as { toSQL: () => { sql: string } }).toSQL().sql - ).toContain('GREATEST') - }) }) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index a89383ccab0..6411c654d10 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -1,33 +1,20 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { - member, - organization, - subscription as subscriptionTable, - user, - userStats, -} from '@sim/db/schema' +import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' -import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm' import type Stripe from 'stripe' import { getEmailSubject, renderCreditPurchaseEmail, renderPaymentFailedEmail, } from '@/components/emails' -import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants' -import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' -import { - COPILOT_USAGE_SOURCES, - getBillingPeriodUsageCostByUser, -} from '@/lib/billing/core/usage-log' +import { isSubscriptionOrgScoped } from '@/lib/billing/core/billing' 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 { 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' @@ -39,6 +26,17 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('StripeInvoiceWebhooks') +/** + * Cycle rollover (usage window advance, final overage collection, + * `billedOverageThisPeriod` reset, last-period bookkeeping) is NOT handled + * here. Usage windows advance automatically — current usage is the attributed + * usage_log ledger for the subscription's current period — and the money + + * bookkeeping close runs off period advance in + * `@/lib/billing/cycle-close`, independent of invoice payload shape. These + * handlers only manage payment lifecycle: block/unblock, notification emails, + * credit purchases, and audit. + */ + /** * Resolve the audit actor for a billing event. For org-scoped subscriptions the * actor is the org owner; for personal subscriptions it is the reference (user) @@ -64,26 +62,6 @@ async function resolveBillingActorId(isOrgScoped: boolean, referenceId: string): } } -function getSubscriptionLinePeriod( - invoice: Stripe.Invoice, - stripeSubscriptionId: string -): { periodStart: Date; periodEnd: Date } | null { - const subscriptionLine = invoice.lines?.data?.find( - (line) => - line.parent?.type === 'subscription_item_details' && - line.parent.subscription_item_details?.subscription === stripeSubscriptionId - ) - - if (!subscriptionLine?.period?.start || !subscriptionLine.period.end) { - return null - } - - return { - periodStart: new Date(subscriptionLine.period.start * 1000), - periodEnd: new Date(subscriptionLine.period.end * 1000), - } -} - const METADATA_SUBSCRIPTION_INVOICE_TYPES = new Set([ 'overage_billing', 'overage_threshold_billing', @@ -438,224 +416,6 @@ export async function getBilledOverageForSubscription(sub: { : 0 } -export async function resetUsageForSubscription(sub: { - plan: string | null - referenceId: string - periodStart?: Date | null - periodEnd?: Date | null -}) { - const billingPeriod = - sub.periodStart && sub.periodEnd ? { start: sub.periodStart, end: sub.periodEnd } : null - - if (await isSubscriptionOrgScoped(sub)) { - const ledgerUsageByUser = billingPeriod - ? await getBillingPeriodUsageCostByUser( - { type: 'organization', id: sub.referenceId }, - billingPeriod - ) - : new Map() - // Copilot-family ledger per user, so last-period copilot mirrors last-period - // cost (baseline + usage_log) instead of capturing the baseline alone. - const copilotLedgerByUser = billingPeriod - ? await getBillingPeriodUsageCostByUser( - { type: 'organization', id: sub.referenceId }, - billingPeriod, - COPILOT_USAGE_SOURCES - ) - : new Map() - - await db.transaction(async (tx) => { - await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) - - const ownerRows = await tx - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, sub.referenceId), eq(member.role, 'owner'))) - .for('update') - .limit(1) - - const ownerId = ownerRows[0]?.userId - if (ownerId) { - await tx - .select({ userId: userStats.userId }) - .from(userStats) - .where(eq(userStats.userId, ownerId)) - .for('update') - .limit(1) - } - - const membersRows = await tx - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) - - const memberIds = membersRows.map((row) => row.userId) - - // Lock every member's userStats before the organization row so this path - // follows the canonical userStats → organization order shared by the - // join, remove, threshold-billing, and storage-transfer paths. Locking - // organization first would invert against them and risk an AB-BA - // deadlock. The per-member UPDATE below re-locks these rows (no-op). - if (memberIds.length > 0) { - await tx - .select({ userId: userStats.userId }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - .for('update') - } - - await tx - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, sub.referenceId)) - .for('update') - .limit(1) - if (memberIds.length > 0) { - const memberStatsRows = await tx - .select({ - userId: userStats.userId, - current: userStats.currentPeriodCost, - currentCopilot: userStats.currentPeriodCopilotCost, - }) - .from(userStats) - .where(inArray(userStats.userId, memberIds)) - - const statsUserIds = memberStatsRows.map((row) => row.userId) - if (statsUserIds.length === 0) { - await tx - .update(organization) - .set({ departedMemberUsage: '0' }) - .where(eq(organization.id, sub.referenceId)) - return - } - - const lastCostByUser = sql.join( - memberStatsRows.map((row) => { - const baseline = toNumber(toDecimal(row.current)) - const ledgerUsage = ledgerUsageByUser.get(row.userId) ?? 0 - const lastPeriodCost = (baseline + ledgerUsage).toString() - return sql`WHEN ${row.userId} THEN ${lastPeriodCost}` - }), - sql` ` - ) - const currentCostByUser = sql.join( - memberStatsRows.map((row) => sql`WHEN ${row.userId} THEN ${row.current ?? '0'}`), - sql` ` - ) - const currentCopilotCostByUser = sql.join( - memberStatsRows.map((row) => sql`WHEN ${row.userId} THEN ${row.currentCopilot ?? '0'}`), - sql` ` - ) - // Last-period copilot = baseline copilot + copilot-family ledger, mirroring - // lastPeriodCost. (The reset below still subtracts only the baseline, since - // the ledger is period-scoped and rolls over on its own.) - const lastCopilotCostByUser = sql.join( - memberStatsRows.map((row) => { - const baselineCopilot = toNumber(toDecimal(row.currentCopilot)) - const copilotLedger = copilotLedgerByUser.get(row.userId) ?? 0 - return sql`WHEN ${row.userId} THEN ${(baselineCopilot + copilotLedger).toString()}` - }), - sql` ` - ) - const capturedLastCost = sql`CASE ${userStats.userId} ${lastCostByUser} ELSE '0' END` - const capturedCurrentCost = sql`CASE ${userStats.userId} ${currentCostByUser} ELSE '0' END` - const capturedCurrentCopilotCost = sql`CASE ${userStats.userId} ${currentCopilotCostByUser} ELSE '0' END` - const capturedLastCopilotCost = sql`CASE ${userStats.userId} ${lastCopilotCostByUser} ELSE '0' END` - - await tx - .update(userStats) - .set({ - lastPeriodCost: capturedLastCost, - lastPeriodCopilotCost: capturedLastCopilotCost, - currentPeriodCost: sql`GREATEST(0, ${userStats.currentPeriodCost} - (${capturedCurrentCost})::decimal)`, - currentPeriodCopilotCost: sql`GREATEST(0, ${userStats.currentPeriodCopilotCost} - (${capturedCurrentCopilotCost})::decimal)`, - billedOverageThisPeriod: '0', - }) - .where(inArray(userStats.userId, statsUserIds)) - } - - await tx - .update(organization) - .set({ departedMemberUsage: '0' }) - .where(eq(organization.id, sub.referenceId)) - }) - } else { - const currentStats = await db - .select({ - current: userStats.currentPeriodCost, - snapshot: userStats.proPeriodCostSnapshot, - currentCopilot: userStats.currentPeriodCopilotCost, - }) - .from(userStats) - .where(eq(userStats.userId, sub.referenceId)) - .limit(1) - if (currentStats.length > 0) { - const current = currentStats[0].current || '0' - const snapshot = toNumber(toDecimal(currentStats[0].snapshot)) - const currentCopilot = currentStats[0].currentCopilot || '0' - const ledgerUsage = billingPeriod - ? await getBillingPeriodUsageCostByUser( - { type: 'user', id: sub.referenceId }, - billingPeriod - ) - : new Map() - const userLedgerUsage = ledgerUsage.get(sub.referenceId) ?? 0 - const copilotLedgerUsage = billingPeriod - ? (( - await getBillingPeriodUsageCostByUser( - { type: 'user', id: sub.referenceId }, - billingPeriod, - COPILOT_USAGE_SOURCES - ) - ).get(sub.referenceId) ?? 0) - : 0 - - // 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) - .set({ - lastPeriodCost: (snapshot + userLedgerUsage).toString(), - // Pre-join personal copilot = the user-scoped copilot ledger only - // (post-join copilot usage is org-attributed, so this captures the - // pre-join portion). The copilot baseline stays with the org via the - // retained currentPeriodCopilotCost, so don't add it here (avoids a - // double count at the org's cycle-close). - lastPeriodCopilotCost: copilotLedgerUsage.toString(), - proPeriodCostSnapshot: '0', - proPeriodCostSnapshotAt: null, - billedOverageThisPeriod: '0', - }) - .where(eq(userStats.userId, sub.referenceId)) - } else { - const totalLastPeriod = ( - toNumber(toDecimal(current)) + - snapshot + - userLedgerUsage - ).toString() - // Delta-reset for the same reason as the org branch above. - await db - .update(userStats) - .set({ - lastPeriodCost: totalLastPeriod, - lastPeriodCopilotCost: ( - toNumber(toDecimal(currentCopilot)) + copilotLedgerUsage - ).toString(), - 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)) - } - } - } -} - /** * Handle credit purchase invoice payment succeeded. */ @@ -854,30 +614,6 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { const { sub } = resolvedInvoice const subIsOrgScoped = await isSubscriptionOrgScoped(sub) - 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 - } - const isProrationInvoice = invoice.billing_reason === 'subscription_update' const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0 @@ -903,19 +639,6 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { }) } - if (wasBlocked && !isProrationInvoice) { - const invoicePeriod = getSubscriptionLinePeriod( - invoice, - resolvedInvoice.stripeSubscriptionId - ) - await resetUsageForSubscription({ - plan: sub.plan, - referenceId: sub.referenceId, - periodStart: invoicePeriod?.periodStart ?? null, - periodEnd: invoicePeriod?.periodEnd ?? null, - }) - } - const entityType = subIsOrgScoped ? 'organization' : 'user' const amountPaid = (invoice.amount_paid ?? 0) / 100 const actorId = await resolveBillingActorId(subIsOrgScoped, sub.referenceId) @@ -1092,268 +815,3 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { throw error } } - -/** - * Handle base invoice finalized → create a separate overage-only invoice - * Note: Enterprise plans no longer have overages - */ -export async function handleInvoiceFinalized(event: Stripe.Event) { - try { - const invoice = event.data.object as Stripe.Invoice - const subscription = invoice.parent?.subscription_details?.subscription - const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id - if (!stripeSubscriptionId) { - logger.info('No subscription found on invoice; skipping finalized handler', { - invoiceId: invoice.id, - }) - return - } - if (invoice.billing_reason && invoice.billing_reason !== 'subscription_cycle') return - - const records = await db - .select() - .from(subscriptionTable) - .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) - .limit(1) - - if (records.length === 0) return - const sub = records[0] - - const invoicePeriod = getSubscriptionLinePeriod(invoice, stripeSubscriptionId) - if (!invoicePeriod) { - logger.error('Missing subscription line period on subscription cycle invoice', { - invoiceId: invoice.id, - stripeSubscriptionId, - }) - if (isEnterprise(sub.plan)) { - await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) - } - return - } - - if (isEnterprise(sub.plan)) { - await resetUsageForSubscription({ - plan: sub.plan, - referenceId: sub.referenceId, - periodStart: invoicePeriod.periodStart, - periodEnd: invoicePeriod.periodEnd, - }) - return - } - - await stripeWebhookIdempotency.executeWithIdempotency( - 'invoice-finalized', - event.id, - async () => { - const stripe = requireStripeClient() - const periodStart = Math.floor(invoicePeriod.periodStart.getTime() / 1000) - const periodEnd = Math.floor(invoicePeriod.periodEnd.getTime() / 1000) - const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) - - const totalOverage = await calculateSubscriptionOverage({ - ...sub, - periodStart: new Date(periodStart * 1000), - periodEnd: new Date(periodEnd * 1000), - }) - - const entityType = (await isSubscriptionOrgScoped(sub)) ? 'organization' : 'user' - const entityId = sub.referenceId - - // Phase 1 — atomic commit. Resolve org owners inside the transaction, - // then lock the tracker row so `billedOverageThisPeriod` is serialized - // against threshold billing, resets, owner transfers, and retries. - const phase1 = await db.transaction(async (tx) => { - await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) - - let trackerUserId = entityId - if (entityType === 'organization') { - const ownerRows = await tx - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, entityId), eq(member.role, 'owner'))) - .for('update') - .limit(1) - const ownerId = ownerRows[0]?.userId - if (!ownerId) { - throw new Error( - `Organization ${entityId} has no owner member; cannot process invoice finalization` - ) - } - trackerUserId = ownerId - } - - const trackerRows = await tx - .select({ billed: userStats.billedOverageThisPeriod }) - .from(userStats) - .where(eq(userStats.userId, trackerUserId)) - .for('update') - .limit(1) - - const billedInTx = trackerRows.length > 0 ? toNumber(toDecimal(trackerRows[0].billed)) : 0 - const remaining = Math.max(0, totalOverage - billedInTx) - - if (remaining === 0) { - return { billedInTx, applied: 0, billed: 0, remaining: 0 } - } - - 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 ? toNumber(toDecimal(lockedBalance[0].creditBalance)) : 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)) - } - } - - await tx - .update(userStats) - .set({ billedOverageThisPeriod: totalOverage.toString() }) - .where(eq(userStats.userId, trackerUserId)) - - return { billedInTx, applied, billed, remaining } - }) - - const creditsApplied = phase1.applied - const amountToBillStripe = phase1.billed - - logger.info('Invoice finalized overage calculation', { - subscriptionId: sub.id, - totalOverage, - billedOverageBeforeTx: phase1.billedInTx, - creditsApplied, - amountToBillStripe, - 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 finalizeIdemKey = `overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - const payIdemKey = `overage-pay:${customerId}:${stripeSubscriptionId}:${billingPeriod}` - - const { paymentMethodId: defaultPaymentMethod, collectionMethod } = - await resolveDefaultPaymentMethod(stripe, stripeSubscriptionId, customerId) - - const effectiveCollectionMethod = collectionMethod ?? 'charge_automatically' - - const overageInvoice = await stripe.invoices.create( - { - customer: customerId, - collection_method: effectiveCollectionMethod, - auto_advance: false, - ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), - metadata: { - type: 'overage_billing', - billingPeriod, - subscriptionId: stripeSubscriptionId, - }, - }, - { idempotencyKey: invoiceIdemKey } - ) - - 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 } - ) - - 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, - {}, - { 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 }, - { idempotencyKey: payIdemKey } - ) - } catch (payError) { - logger.error('Failed to auto-pay overage invoice', { - error: payError, - invoiceId: finalized.id, - }) - } - } - } - } - - // 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, - periodStart: invoicePeriod.periodStart, - periodEnd: invoicePeriod.periodEnd, - }) - - return { totalOverage, creditsApplied, amountToBillStripe } - } - ) - } catch (error) { - logger.error('Failed to handle invoice finalized', { error }) - throw error - } -} diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 6ce6c72f5de..483929dfdca 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -5,15 +5,13 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, ne } from 'drizzle-orm' import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +import { writeFinalPeriodBookkeeping } from '@/lib/billing/cycle-close' import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' import { isEnterprise, isPaid, isPro, isTeam } 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, -} from '@/lib/billing/webhooks/invoices' +import { getBilledOverageForSubscription } from '@/lib/billing/webhooks/invoices' import { captureServerEvent } from '@/lib/posthog/server' import { detachOrganizationWorkspaces } from '@/lib/workspaces/organization-workspaces' @@ -185,35 +183,17 @@ export async function handleSubscriptionCreated( const wasFreePreviously = otherActiveSubscriptions.length === 0 const isPaidPlan = isPaid(subscriptionData.plan) - if (wasFreePreviously && isPaidPlan) { - logger.info('Detected free -> paid transition, resetting usage', { - subscriptionId: subscriptionData.id, - referenceId: subscriptionData.referenceId, - plan: subscriptionData.plan, - }) - - await resetUsageForSubscription({ - plan: subscriptionData.plan, - referenceId: subscriptionData.referenceId, - periodStart: subscriptionData.periodStart ?? null, - periodEnd: subscriptionData.periodEnd ?? null, - }) - - logger.info('Successfully reset usage for free -> paid transition', { - subscriptionId: subscriptionData.id, - referenceId: subscriptionData.referenceId, - plan: subscriptionData.plan, - }) - } else { - logger.info('No usage reset needed', { - subscriptionId: subscriptionData.id, - referenceId: subscriptionData.referenceId, - plan: subscriptionData.plan, - wasFreePreviously, - isPaidPlan, - otherActiveSubscriptionsCount: otherActiveSubscriptions.length, - }) - } + // No usage reset on free -> paid: usage is the attributed ledger, and + // the new subscription's period window starts empty by construction + // (rows are stamped with the paid period at write time). + logger.info('Processed subscription creation', { + subscriptionId: subscriptionData.id, + referenceId: subscriptionData.referenceId, + plan: subscriptionData.plan, + wasFreePreviously, + isPaidPlan, + otherActiveSubscriptionsCount: otherActiveSubscriptions.length, + }) if (wasFreePreviously && isPaidPlan) { // Best-effort instrumentation; a transient DB error here must never abort @@ -303,7 +283,7 @@ export async function handleSubscriptionDeleted( const stripe = requireStripeClient() if (isEnterprise(subscription.plan)) { - await resetUsageForSubscription({ + await writeFinalPeriodBookkeeping({ plan: subscription.plan, referenceId: subscription.referenceId, periodStart: subscription.periodStart ?? null, @@ -423,7 +403,7 @@ export async function handleSubscriptionDeleted( }) } - await resetUsageForSubscription({ + await writeFinalPeriodBookkeeping({ plan: subscription.plan, referenceId: subscription.referenceId, periodStart: subscription.periodStart ?? null, diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 00d04a72b2d..8b811c0b7de 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -1,9 +1,7 @@ import { db, dbFor } from '@sim/db' import { - member, organization, usageLog, - userStats, user as userTable, workflow, workflowExecutionLogs, @@ -1301,16 +1299,6 @@ export class ExecutionLogger implements IExecutionLoggerService { payerSubscription.plan, payerSubscription.seats ) - let orgBaseline = 0 - if (exactBillingContext.billingPeriod.source !== 'reporting') { - const [{ sum }] = await db - .select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` }) - .from(member) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .where(eq(member.organizationId, organizationId)) - .limit(1) - orgBaseline = Number.parseFloat(String(sum ?? '0')) - } const { getBillingPeriodUsageCost } = await import('@/lib/billing/core/usage-log') const orgLedger = await getBillingPeriodUsageCost( billingAttribution.billingEntity, @@ -1321,7 +1309,7 @@ export class ExecutionLogger implements IExecutionLoggerService { organizationId, planName: getDisplayPlanName(payerSubscription.plan), orgLimit, - orgUsageBefore: orgBaseline + orgLedger, + orgUsageBefore: orgLedger, } } else if (billingAttribution?.billingEntity.type === 'user' && usr?.email) { const sub = await getHighestPriorityPersonalSubscription(usr.id) diff --git a/docker/crontab b/docker/crontab index e12bb729a0b..f945efafcca 100644 --- a/docker/crontab +++ b/docker/crontab @@ -42,6 +42,9 @@ SHELL=/bin/sh # Microsoft Graph subscription renewal (Teams chat triggers expire after ~3 days) 0 */12 * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/renew-subscriptions" +# Billing cycle close: final overage collection + per-period tracker reset for elapsed periods +0 */6 * * * curl -fsS -m 120 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/billing-cycle-close" + # Reclaims sandbox images 30 4 * * * curl -fsS -m 300 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-sandbox-images" diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 88a5a1f77c2..6f71948875a 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -1387,6 +1387,15 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + billingCycleClose: + enabled: true + name: billing-cycle-close + schedule: "0 */6 * * *" + path: "/api/cron/billing-cycle-close" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + reconcileBillingSeats: enabled: true name: reconcile-billing-seats diff --git a/packages/db/migrations/0305_add_subscription_last_closed_period_start.sql b/packages/db/migrations/0305_add_subscription_last_closed_period_start.sql new file mode 100644 index 00000000000..47b1e8cc227 --- /dev/null +++ b/packages/db/migrations/0305_add_subscription_last_closed_period_start.sql @@ -0,0 +1 @@ +ALTER TABLE "subscription" ADD COLUMN "last_closed_period_start" timestamp; \ No newline at end of file diff --git a/packages/db/migrations/meta/0305_snapshot.json b/packages/db/migrations/meta/0305_snapshot.json new file mode 100644 index 00000000000..84a6241fe96 --- /dev/null +++ b/packages/db/migrations/meta/0305_snapshot.json @@ -0,0 +1,20120 @@ +{ + "id": "10267de3-7569-40c6-895c-800b736a161e", + "prevId": "8b1525ae-147f-4e93-a624-0f5cff95a1d5", + "version": "7", + "dialect": "postgresql", + "tables": { + "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_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 + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "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_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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.background_work_status": { + "name": "background_work_status", + "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": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_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": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "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": { + "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_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "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 + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "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_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 + }, + "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": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "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": { + "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": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "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_messages": { + "name": "copilot_messages", + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "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": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "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_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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 + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "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 + }, + "last_refreshed_at": { + "name": "last_refreshed_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": { + "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_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "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_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_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_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "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_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_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 + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_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": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "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.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "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": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_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.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "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 + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_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": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_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": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "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 + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "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_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "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" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "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": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "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 + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "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": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "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": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "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()" + }, + "updated_at": { + "name": "updated_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": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "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": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "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": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_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_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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.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 + }, + "folder_id": { + "name": "folder_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_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_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_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "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": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_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()" + }, + "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_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "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_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "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_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_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_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "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_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "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()" + }, + "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": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "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.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_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 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_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": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_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 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_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 + }, + "next_resume_at": { + "name": "next_resume_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": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "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()" + }, + "is_default": { + "name": "is_default", + "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_organization_name_unique": { + "name": "permission_group_organization_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_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = 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 + }, + "organization_id": { + "name": "organization_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_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_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.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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_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.pinned_item": { + "name": "pinned_item", + "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 + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_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": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_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": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": "'[]'" + }, + "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": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "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.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_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": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "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": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_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": {} + } + }, + "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 + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "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 + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "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.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": 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": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "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 + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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 + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "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 + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "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.table_jobs": { + "name": "table_jobs", + "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 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_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": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "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 + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "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": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "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 + }, + "knowledge_base_id": { + "name": "knowledge_base_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 + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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 + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_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": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_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": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "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_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 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "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 + }, + "folder_id": { + "name": "folder_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 + }, + "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 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_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": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "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_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": 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": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "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 + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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": {} + }, + "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_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "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": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "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": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "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 + }, + "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 + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "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_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "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": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "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_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "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 + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": 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_operation": { + "name": "workflow_deployment_operation", + "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": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "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 + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "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_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "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_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": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "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": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "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_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "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 + }, + "deployment_operation_id": { + "name": "deployment_operation_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 + }, + "infra_retry_count": { + "name": "infra_retry_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 + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_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": { + "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_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "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_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "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_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_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_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": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "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_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "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 + }, + "folder_id": { + "name": "folder_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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "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_folder_name_active_unique": { + "name": "workspace_files_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": "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_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_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_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_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": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "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_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "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_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "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_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "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()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "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 + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "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_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_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": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "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.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "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_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "managed_oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "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 82d62c563ed..d704f4f261d 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2129,6 +2129,13 @@ "when": 1787598098228, "tag": "0304_slippery_carmella_unuscione", "breakpoints": true + }, + { + "idx": 305, + "version": "7", + "when": 1787687900983, + "tag": "0305_add_subscription_last_closed_period_start", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index a53c47e25a0..018aad5c80d 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1196,23 +1196,22 @@ export const userStats = pgTable('user_stats', { totalCost: decimal('total_cost').notNull().default('0'), currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $5 (1,000 credits) for free plan, null for team/enterprise usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(), - /** - * Active per-period baseline (not a per-usage hot-path counter). Current usage - * = this baseline + attributed usage_log rows for the period; reset at rollover. - */ + /** @deprecated No readers or writers; usage is the attributed usage_log ledger. Drop via DROP COLUMN in a follow-up migration. */ currentPeriodCost: decimal('current_period_cost').notNull().default('0'), - lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period + /** Previous-period usage; written by the cycle-close sweep from ledger sums. */ + lastPeriodCost: decimal('last_period_cost').default('0'), /** * Threshold/final billing tracker. * - * This is intentionally still written when threshold billing or invoice - * finalization serializes overage collection. It is not incremented by the - * ordinary per-usage ledger write path. + * Incremented when threshold billing collects overage mid-period; reset to + * zero by the cycle-close sweep at period rollover. It is not incremented + * by the ordinary per-usage ledger write path. */ 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). + /** @deprecated No readers or writers; ledger entity stamps attribute pre/post-join usage. Drop via DROP COLUMN in a follow-up migration. */ + proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), + /** @deprecated No readers or writers; see proPeriodCostSnapshot. Drop via DROP COLUMN in a follow-up migration. */ + proPeriodCostSnapshotAt: timestamp('pro_period_cost_snapshot_at'), /** * Credit balance tracker. * @@ -1222,9 +1221,9 @@ export const userStats = pgTable('user_stats', { creditBalance: decimal('credit_balance').notNull().default('0'), /** @deprecated Not written; report Copilot cost from usage_log. Legacy/admin reads only. */ totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'), - /** Active per-period Copilot baseline; reset at rollover (not a per-usage counter). */ + /** @deprecated No readers or writers; Copilot usage is the copilot-source usage_log ledger. Drop via DROP COLUMN in a follow-up migration. */ currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'), - /** Previous-period Copilot cost; set at rollover. */ + /** Previous-period Copilot cost; written by the cycle-close sweep from copilot-source ledger sums. */ lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'), /** @deprecated Not written; report Copilot tokens from usage_log. Legacy/admin reads only. */ totalCopilotTokens: bigint('total_copilot_tokens', { mode: 'number' }).notNull().default(0), @@ -1362,6 +1361,16 @@ export const subscription = pgTable( billingInterval: text('billing_interval'), stripeScheduleId: text('stripe_schedule_id'), metadata: json('metadata'), + /** + * Durable cycle-close marker: the `periodStart` of the most recent period + * whose close (final overage collection, `billedOverageThisPeriod` reset, + * last-period bookkeeping) has been committed. The daily cycle-close sweep + * closes the previous period whenever this lags the row's `periodStart`, + * then advances it. Null = never initialized; the first sweep initializes + * it to the current `periodStart` without billing so historical periods + * are never retroactively closed. + */ + lastClosedPeriodStart: timestamp('last_closed_period_start'), }, (table) => ({ referenceStatusIdx: index('subscription_reference_status_idx').on( @@ -1582,6 +1591,7 @@ export const organization = pgTable('organization', { .$type>() .notNull() .default({}), + /** @deprecated No readers or writers; a departed member's ledger rows stay stamped to the org's period, so nothing needs capturing. Drop via DROP COLUMN in a follow-up migration. */ departedMemberUsage: decimal('departed_member_usage').notNull().default('0'), /** * Organization credit balance tracker. diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 068a12f6944..061063a1627 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -520,6 +520,7 @@ export const schemaMock = { trialStart: 'subscription.trialStart', trialEnd: 'subscription.trialEnd', metadata: 'subscription.metadata', + lastClosedPeriodStart: 'subscription.lastClosedPeriodStart', }, rateLimitBucket: { key: 'rateLimitBucket.key', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index dc92dbc762f..a5fb0b221f9 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -89,6 +89,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/cron/cleanup-stale-executions/route.ts', 'apps/sim/app/api/cron/cleanup-sandbox-images/route.ts', 'apps/sim/app/api/cron/renew-subscriptions/route.ts', + 'apps/sim/app/api/cron/billing-cycle-close/route.ts', 'apps/sim/app/api/cron/reconcile-billing-seats/route.ts', 'apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts', 'apps/sim/app/api/cron/run-data-drains/route.ts', From 0acbfc5dcce5479dfdf21c59fc99751dd092005d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 13:51:00 -0700 Subject: [PATCH 02/14] fix(billing): include departed actors in close refresh and gate threshold billing on close currency Cycle close now unions current members with every actor holding org-stamped ledger rows in the closed period, so a departed member's daily-refresh consumption offsets the final overage exactly like their billed usage. Threshold billing defers with a pending-cycle-close no-op while a subscription's close marker lags its current period, so the shared billedOverageThisPeriod tracker can never mix an elapsed period's settlements with the new period's. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/billing/cycle-close.test.ts | 58 ++++++++++++++++++- apps/sim/lib/billing/cycle-close.ts | 39 ++++++++++++- .../sim/lib/billing/threshold-billing.test.ts | 44 ++++++++++++++ apps/sim/lib/billing/threshold-billing.ts | 22 +++++++ 4 files changed, 161 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index f4938ca1079..f114fe57533 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -79,7 +79,11 @@ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, })) -import { closeElapsedBillingPeriod, sweepBillingCycleCloses } from '@/lib/billing/cycle-close' +import { + closeElapsedBillingPeriod, + isSubscriptionCycleCloseCurrent, + sweepBillingCycleCloses, +} from '@/lib/billing/cycle-close' type SubInput = Parameters[0] @@ -210,6 +214,27 @@ describe('closeElapsedBillingPeriod', () => { expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) }) + it('includes departed members with billed ledger usage in the refresh actor set', async () => { + // 'departed-1' has org-attributed rows in the closed period but no member + // row anymore; their refresh consumption must still offset the overage. + mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue( + new Map([ + ['owner-1', 100], + ['departed-1', 50], + ]) + ) + queueOrgCloseReads() + + await closeElapsedBillingPeriod(subRow()) + + expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith( + expect.objectContaining({ + pooledLedgerUsage: 150, + memberIds: ['owner-1', 'departed-1'], + }) + ) + }) + it('applies organization credits before invoicing and skips Stripe when covered', async () => { queueOrgCloseReads({ orgRow: { creditBalance: '100' } }) @@ -292,6 +317,37 @@ describe('closeElapsedBillingPeriod', () => { }) }) +describe('isSubscriptionCycleCloseCurrent', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('is current when the marker has caught up to the period start', async () => { + queueTableRows(schemaMock.subscription, [ + { periodStart: PERIOD_START, lastClosedPeriodStart: PERIOD_START }, + ]) + await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(true) + }) + + it('is pending when the marker lags the period start or was never initialized', async () => { + queueTableRows(schemaMock.subscription, [ + { periodStart: PERIOD_START, lastClosedPeriodStart: PREV_PERIOD_START }, + ]) + await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(false) + + queueTableRows(schemaMock.subscription, [ + { periodStart: PERIOD_START, lastClosedPeriodStart: null }, + ]) + await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(false) + }) + + it('is current when the subscription has no period to close', async () => { + queueTableRows(schemaMock.subscription, [{ periodStart: null, lastClosedPeriodStart: null }]) + await expect(isSubscriptionCycleCloseCurrent('sub-1')).resolves.toBe(true) + }) +}) + describe('sweepBillingCycleCloses', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 79f06b72eed..61f44e8d5c8 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -63,6 +63,35 @@ function hasEnterpriseReportingAnchor(sub: SubscriptionRow): boolean { ) } +/** + * Whether a subscription's previous period has already been closed — i.e. the + * durable close marker has caught up to the current `periodStart`. + * + * Threshold billing gates on this so the shared `billedOverageThisPeriod` + * tracker never mixes periods: after a rollover but before the sweep closes + * the elapsed period, a new-period settlement would be subtracted from the + * elapsed period's final overage and then wiped by the close's tracker reset, + * under-billing one period and double-billing the other. Skipping settlement + * until the close lands (sweep cadence, ≤6h) removes the race; a null marker + * (pre-first-sweep) also gates, and a null `periodStart` cannot race at all. + */ +export async function isSubscriptionCycleCloseCurrent(subscriptionId: string): Promise { + const [row] = await db + .select({ + periodStart: subscriptionTable.periodStart, + lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart, + }) + .from(subscriptionTable) + .where(eq(subscriptionTable.id, subscriptionId)) + .limit(1) + + if (!row?.periodStart) return true + return ( + row.lastClosedPeriodStart !== null && + row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime() + ) +} + /** * Advance the durable close marker to `periodStart`, guarded so concurrent * closers and replays collapse to one winner. Returns false when another @@ -191,6 +220,14 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise row.role === 'owner')?.userId ?? null) : sub.referenceId + // Every actor whose org-attributed usage is billed at this close, including + // members who departed mid-period: their ledger rows stay stamped to this + // organization's period, so their daily-refresh consumption must offset the + // overage exactly like a current member's. Current members with no rows stay + // in the set for their refresh bounds. + const overageActorIds = orgScoped + ? [...new Set([...memberIds, ...usageByUser.keys()])] + : memberIds // Final overage for the closed period (enterprise never bills overage). let totalOverage = 0 @@ -203,7 +240,7 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise ({ @@ -32,6 +33,7 @@ const { mockIsFree: vi.fn(), mockIsOrgScopedSubscription: vi.fn(), mockIsOrganizationBillingBlocked: vi.fn(), + mockIsSubscriptionCycleCloseCurrent: vi.fn(), mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), })) @@ -61,6 +63,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost, })) +vi.mock('@/lib/billing/cycle-close', () => ({ + isSubscriptionCycleCloseCurrent: mockIsSubscriptionCycleCloseCurrent, +})) + vi.mock('@/lib/billing/plan-helpers', () => ({ isEnterprise: mockIsEnterprise, isFree: mockIsFree, @@ -157,6 +163,7 @@ function queueOrgReads({ } const usableOrgSubscription = { + id: 'sub-db-team-1', plan: 'team', seats: 2, periodStart: new Date('2026-05-01T00:00:00.000Z'), @@ -178,6 +185,7 @@ describe('checkAndBillOverageThreshold', () => { mockIsEnterprise.mockReturnValue(false) mockIsOrgScopedSubscription.mockReturnValue(false) mockGetBillingPeriodUsageCost.mockResolvedValue(0) + mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(true) }) afterAll(() => { @@ -522,6 +530,42 @@ describe('checkAndBillOverageThreshold', () => { expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) + it('defers personal settlement while the previous period cycle close is pending', async () => { + mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(false) + mockCalculateSubscriptionOverage.mockResolvedValue(250) + + await expect( + checkAndBillOverageThreshold('user-1', undefined, { + onError: 'throw', + expectedBillingPeriod, + }) + ).resolves.toEqual({ status: 'no-op', reason: 'pending-cycle-close' }) + + expect(mockIsSubscriptionCycleCloseCurrent).toHaveBeenCalledWith(userSubscription.id) + expect(mockCalculateSubscriptionOverage).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + + it('defers organization settlement while the previous period cycle close is pending', async () => { + mockIsOrgScopedSubscription.mockReturnValue(true) + mockIsOrganizationBillingBlocked.mockResolvedValue(false) + mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) + mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(false) + + await expect( + checkAndBillOverageThreshold('user-1', undefined, { + onError: 'throw', + expectedBillingPeriod, + }) + ).resolves.toEqual({ status: 'no-op', reason: 'pending-cycle-close' }) + + expect(mockIsSubscriptionCycleCloseCurrent).toHaveBeenCalledWith(usableOrgSubscription.id) + expect(mockComputeOrgOverageAmount).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + it('wraps lock timeouts in markerless strict mode', async () => { queuePersonalReads() mockCalculateSubscriptionOverage.mockResolvedValue(250) diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index f16018688fe..baeb72c4f2f 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -14,6 +14,7 @@ import { getOrganizationSubscriptionUsable, } from '@/lib/billing/core/subscription' import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' +import { isSubscriptionCycleCloseCurrent } from '@/lib/billing/cycle-close' import { isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionAccess, @@ -53,6 +54,7 @@ export type ThresholdSettlementNoOpReason = | 'billing-blocked' | 'billing-ineligible' | 'no-subscription' + | 'pending-cycle-close' | 'plan-ineligible' export type ThresholdSettlementOutcome = @@ -279,6 +281,15 @@ export async function checkAndBillOverageThreshold( return checkAndBillOrganizationOverageThreshold(userSubscription.referenceId, options) } + // Defer settlement while the previous period's cycle close is pending so + // `billedOverageThisPeriod` never mixes periods (see + // `isSubscriptionCycleCloseCurrent`). The sweep closes it within hours and + // a later threshold attempt settles normally. + if (!(await isSubscriptionCycleCloseCurrent(userSubscription.id))) { + logger.debug('Previous period cycle close pending; deferring threshold billing', { userId }) + return noOp(options, 'pending-cycle-close') + } + const currentOverage = await calculateSubscriptionOverage({ id: userSubscription.id, plan: userSubscription.plan, @@ -530,6 +541,17 @@ async function checkAndBillOrganizationOverageThreshold( return noOp(options, 'billing-blocked') } + // Defer settlement while the previous period's cycle close is pending so + // `billedOverageThisPeriod` never mixes periods (see + // `isSubscriptionCycleCloseCurrent`). The sweep closes it within hours and + // a later threshold attempt settles normally. + if (!(await isSubscriptionCycleCloseCurrent(orgSubscription.id))) { + logger.debug('Previous period cycle close pending; deferring org threshold billing', { + organizationId, + }) + return noOp(options, 'pending-cycle-close') + } + logger.debug('Found organization subscription', { organizationId, plan: orgSubscription.plan, From ad58f8319031ced94f0049b4afcc98498db7448d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 14:10:21 -0700 Subject: [PATCH 03/14] fix(billing): seal cycle-close races and align refresh actors with billed usage Threshold settlement revalidates the close marker and period under the tracker lock via the same isSubscriptionCycleCloseCurrent predicate the preflight uses, so a rollover between check and transaction aborts as a concurrent-state retry instead of settling against the wrong cycle. Terminal bookkeeping claims the close marker with its tracker reset, so a deletion racing an in-flight sweep close serializes through the one marker and the loser rolls back rather than re-billing settled overage; anchored enterprise deletions only claim the marker since their windows derive from the anchor, not Stripe bounds. A close with overage due but missing Stripe identifiers now defers loudly instead of claiming the marker and silently forgiving the money, the closed window's start derives from the ledger's own period stamps so anchor-day drift cannot misalign the refresh window, calculateSubscriptionOverage unions departed ledger actors into the org refresh deduction like the close does, and blocked accounts report their real ledger usage while staying blocked. Co-Authored-By: Claude Opus 5 (1M context) --- .../calculations/usage-monitor.test.ts | 34 ++++++ .../lib/billing/calculations/usage-monitor.ts | 16 ++- apps/sim/lib/billing/core/billing.test.ts | 45 +++++++- apps/sim/lib/billing/core/billing.ts | 14 ++- apps/sim/lib/billing/cycle-close.test.ts | 82 ++++++++++++++ apps/sim/lib/billing/cycle-close.ts | 105 +++++++++++++++--- .../sim/lib/billing/threshold-billing.test.ts | 23 ++++ apps/sim/lib/billing/threshold-billing.ts | 36 ++++++ apps/sim/lib/billing/webhooks/subscription.ts | 5 + 9 files changed, 336 insertions(+), 24 deletions(-) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index c1c62f3f11c..292c8b76d59 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -53,6 +53,7 @@ import { checkBillingBlocked, checkBillingEntityBlocked, checkOrganizationMemberUsageLimit, + checkServerSideUsageLimits, checkUsageStatus, } from '@/lib/billing/calculations/usage-monitor' @@ -274,6 +275,39 @@ describe('checkUsageStatus', () => { }) }) +describe('checkServerSideUsageLimits', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isHosted: true, isBillingEnabled: true }) + mockGetBillingPeriodUsageCost.mockResolvedValue(125) + }) + + it('keeps blocked accounts blocked while reporting their real ledger usage', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: true, blockedReason: 'payment_failed' }]) + const subscription = { + referenceId: 'user-1', + plan: 'pro', + status: 'active', + seats: 1, + periodStart: new Date('2026-06-01T00:00:00.000Z'), + periodEnd: new Date('2026-07-01T00:00:00.000Z'), + } + + const result = await checkServerSideUsageLimits('user-1', subscription) + + expect(result).toMatchObject({ isExceeded: true, currentUsage: 125, limit: 0 }) + expect(result.message).toBeTruthy() + expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith( + { type: 'user', id: 'user-1' }, + expect.objectContaining({ + start: subscription.periodStart, + end: subscription.periodEnd, + }) + ) + }) +}) + describe('checkBillingBlocked', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 48128a8493e..52057463709 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -332,7 +332,21 @@ export async function checkServerSideUsageLimits( const blocked = await checkBillingBlocked(userId) if (blocked.blocked) { - return { isExceeded: true, currentUsage: 0, limit: 0, message: blocked.message } + // Enforcement stays blocked, but surfaced usage must be the real ledger + // value — `/api/users/me/usage-limits` exposes it as `currentPeriodCost`. + const sub = + preloadedSubscription !== undefined + ? preloadedSubscription + : await getHighestPrioritySubscription(userId) + const subIsOrgScoped = isOrgScopedSubscription(sub, userId) + const billingEntity: BillingEntity = + subIsOrgScoped && sub + ? { type: 'organization', id: sub.referenceId } + : { type: 'user', id: userId } + const billingPeriod = preloadedBillingContext?.billingPeriod ?? + resolveSubscriptionUsagePeriod(sub) ?? { ...defaultBillingPeriod(), source: 'default' } + const currentUsage = await getBillingPeriodUsageCost(billingEntity, billingPeriod) + return { isExceeded: true, currentUsage, limit: 0, message: blocked.message } } const usageData = await checkUsageStatus(userId, preloadedSubscription, preloadedBillingContext) diff --git a/apps/sim/lib/billing/core/billing.test.ts b/apps/sim/lib/billing/core/billing.test.ts index 84078888d00..718bf1cd3f3 100644 --- a/apps/sim/lib/billing/core/billing.test.ts +++ b/apps/sim/lib/billing/core/billing.test.ts @@ -1,24 +1,28 @@ /** * @vitest-environment node */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMock, dbChainMockFns, queueTableRows, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockComputeDailyRefreshConsumed, mockEnsureUserStatsExists, mockGetBillingPeriodUsageCost, + mockGetBillingPeriodUsageCostByUser, mockGetBillingPeriodUsageCostWithSourceSubset, mockGetHighestPriorityPersonalSubscription, mockGetHighestPrioritySubscription, + mockGetOrgMemberRefreshBounds, mockResolveBillingInterval, } = vi.hoisted(() => ({ mockComputeDailyRefreshConsumed: vi.fn(), mockEnsureUserStatsExists: vi.fn(), mockGetBillingPeriodUsageCost: vi.fn(), + mockGetBillingPeriodUsageCostByUser: vi.fn(), mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(), mockGetHighestPriorityPersonalSubscription: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), + mockGetOrgMemberRefreshBounds: vi.fn(), mockResolveBillingInterval: vi.fn(), })) @@ -37,15 +41,16 @@ vi.mock('@/lib/billing/core/usage', () => ({ vi.mock('@/lib/billing/core/usage-log', () => ({ COPILOT_USAGE_SOURCES: ['copilot'], getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost, + getBillingPeriodUsageCostByUser: mockGetBillingPeriodUsageCostByUser, getBillingPeriodUsageCostWithSourceSubset: mockGetBillingPeriodUsageCostWithSourceSubset, })) vi.mock('@/lib/billing/credits/daily-refresh', () => ({ computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed, - getOrgMemberRefreshBounds: vi.fn(), + getOrgMemberRefreshBounds: mockGetOrgMemberRefreshBounds, })) -import { getPersonalBillingSummary } from '@/lib/billing/core/billing' +import { calculateSubscriptionOverage, getPersonalBillingSummary } from '@/lib/billing/core/billing' describe('getPersonalBillingSummary', () => { beforeEach(() => { @@ -120,3 +125,37 @@ describe('getPersonalBillingSummary', () => { ) }) }) + +describe('calculateSubscriptionOverage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetOrgMemberRefreshBounds.mockResolvedValue({}) + mockComputeDailyRefreshConsumed.mockResolvedValue(0) + }) + + it('includes departed ledger actors in the org refresh deduction', async () => { + queueTableRows(schemaMock.organization, [{ id: 'org-1' }]) // isSubscriptionOrgScoped + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) // current members + mockGetBillingPeriodUsageCostByUser.mockResolvedValue( + new Map([ + ['owner-1', 100], + ['departed-1', 60], + ]) + ) + + await calculateSubscriptionOverage({ + id: 'sub-1', + plan: 'team', + referenceId: 'org-1', + seats: 2, + periodStart: new Date('2026-07-01T00:00:00.000Z'), + periodEnd: new Date('2026-08-01T00:00:00.000Z'), + }) + + expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith( + expect.objectContaining({ + userIds: expect.arrayContaining(['owner-1', 'departed-1']), + }) + ) + }) +}) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 2e28e5099d7..5c459339af8 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -10,6 +10,7 @@ import { ensureUserStatsExists } from '@/lib/billing/core/usage' import { COPILOT_USAGE_SOURCES, getBillingPeriodUsageCost, + getBillingPeriodUsageCostByUser, getBillingPeriodUsageCostWithSourceSubset, } from '@/lib/billing/core/usage-log' import { @@ -188,14 +189,19 @@ export async function calculateSubscriptionOverage(sub: { .select({ userId: member.userId }) .from(member) .where(eq(member.organizationId, sub.referenceId)) - const memberIds = memberRows.map((row) => row.userId) - const ledgerUsage = + const usageByUser = sub.periodStart && sub.periodEnd - ? await getBillingPeriodUsageCost( + ? await getBillingPeriodUsageCostByUser( { type: 'organization', id: sub.referenceId }, { start: sub.periodStart, end: sub.periodEnd } ) - : 0 + : new Map() + let ledgerUsage = 0 + for (const cost of usageByUser.values()) ledgerUsage += cost + // Union current members with every actor holding org-attributed rows this + // period: a member who departed mid-period still bills here, so their + // daily-refresh consumption must offset the overage too. + const memberIds = [...new Set([...memberRows.map((row) => row.userId), ...usageByUser.keys()])] const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({ plan: sub.plan, diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index f114fe57533..8cb40588c16 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -83,6 +83,7 @@ import { closeElapsedBillingPeriod, isSubscriptionCycleCloseCurrent, sweepBillingCycleCloses, + writeFinalPeriodBookkeeping, } from '@/lib/billing/cycle-close' type SubInput = Parameters[0] @@ -214,6 +215,31 @@ describe('closeElapsedBillingPeriod', () => { expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) }) + it('defers the close when overage is due but Stripe identifiers are missing', async () => { + const result = await closeElapsedBillingPeriod(subRow({ stripeCustomerId: null })) + + expect(result.status).toBe('skipped') + // No marker claim, no money, no bookkeeping — the sweep retries next run. + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + + it('derives the closed window from the ledger period stamps when they drift from calendar math', async () => { + // Rows for the elapsed period are stamped starting Jul 3 (anchor drift); + // the stamped boundary — not periodStart minus one interval — must bound + // the refresh window. + const stampedPrevStart = new Date('2026-07-03T00:00:00.000Z') + queueTableRows(schemaMock.usageLog, [{ start: stampedPrevStart }]) + queueOrgCloseReads() + + await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: stampedPrevStart })) + + expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith( + expect.objectContaining({ periodStart: stampedPrevStart, periodEnd: PERIOD_START }) + ) + }) + it('includes departed members with billed ledger usage in the refresh actor set', async () => { // 'departed-1' has org-attributed rows in the closed period but no member // row anymore; their refresh consumption must still offset the overage. @@ -317,6 +343,62 @@ describe('closeElapsedBillingPeriod', () => { }) }) +describe('writeFinalPeriodBookkeeping', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsSubscriptionOrgScoped.mockResolvedValue(true) + mockIsEnterprise.mockReturnValue(false) + mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 25]])) + dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) + }) + + it('resets trackers, writes last-period sums, and claims the terminal marker in one transaction', async () => { + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + + await writeFinalPeriodBookkeeping({ + id: 'sub-1', + plan: 'team', + referenceId: 'org-1', + periodStart: PERIOD_START, + periodEnd: new Date('2026-09-01T00:00:00.000Z'), + }) + + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + const bookkeepingSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).billedOverageThisPeriod === '0' + ) + expect(bookkeepingSet).toBeDefined() + const markerSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date + ) + expect(markerSet).toBeDefined() + }) + + it('only claims the marker for reporting-anchor enterprise subscriptions', async () => { + mockIsEnterprise.mockReturnValue(true) + + await writeFinalPeriodBookkeeping({ + id: 'sub-1', + plan: 'enterprise', + referenceId: 'org-1', + periodStart: PERIOD_START, + periodEnd: new Date('2026-09-01T00:00:00.000Z'), + metadata: { reportingPeriodAnchorDate: '2026-05-01' }, + }) + + expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled() + const markerSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date + ) + expect(markerSet).toBeDefined() + const bookkeepingSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).billedOverageThisPeriod === '0' + ) + expect(bookkeepingSet).toBeUndefined() + }) +}) + describe('isSubscriptionCycleCloseCurrent', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 61f44e8d5c8..7957a52c091 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -1,6 +1,12 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { member, organization, subscription as subscriptionTable, userStats } from '@sim/db/schema' +import { + member, + organization, + subscription as subscriptionTable, + usageLog, + userStats, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' @@ -18,6 +24,7 @@ import { ENTITLED_SUBSCRIPTION_STATUSES, getPlanPricing } from '@/lib/billing/su import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import type { DbOrTx } from '@/lib/db/types' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('BillingCycleClose') @@ -55,7 +62,7 @@ function minusOneInterval(date: Date, billingInterval: string | null): Date { return result } -function hasEnterpriseReportingAnchor(sub: SubscriptionRow): boolean { +function hasEnterpriseReportingAnchor(sub: { plan: string | null; metadata?: unknown }): boolean { return ( isEnterprise(sub.plan) && isRecordLike(sub.metadata) && @@ -74,9 +81,19 @@ function hasEnterpriseReportingAnchor(sub: SubscriptionRow): boolean { * under-billing one period and double-billing the other. Skipping settlement * until the close lands (sweep cadence, ≤6h) removes the race; a null marker * (pre-first-sweep) also gates, and a null `periodStart` cannot race at all. + * + * The same predicate revalidates inside the settlement transaction (pass the + * `tx` as `executor` plus the `expectedPeriodStart` the overage was computed + * against): the unlocked preflight leaves a window where a rollover and its + * close can commit first, so the settlement re-checks under the tracker lock + * and aborts when the period moved. */ -export async function isSubscriptionCycleCloseCurrent(subscriptionId: string): Promise { - const [row] = await db +export async function isSubscriptionCycleCloseCurrent( + subscriptionId: string, + options: { executor?: DbOrTx; expectedPeriodStart?: Date | null } = {} +): Promise { + const executor = options.executor ?? db + const [row] = await executor .select({ periodStart: subscriptionTable.periodStart, lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart, @@ -85,7 +102,14 @@ export async function isSubscriptionCycleCloseCurrent(subscriptionId: string): P .where(eq(subscriptionTable.id, subscriptionId)) .limit(1) - if (!row?.periodStart) return true + if (options.expectedPeriodStart) { + if (!row?.periodStart || row.periodStart.getTime() !== options.expectedPeriodStart.getTime()) { + return false + } + } else if (!row?.periodStart) { + return true + } + return ( row.lastClosedPeriodStart !== null && row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime() @@ -168,7 +192,26 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise`max(${usageLog.billingPeriodStart})` }) + .from(usageLog) + .where( + and( + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + eq(usageLog.billingPeriodEnd, periodStart) + ) + ) + const expectedPrevStart = prevStamp?.start ?? minusOneInterval(periodStart, sub.billingInterval) // Money and bookkeeping cover exactly one period. A marker further back // than one interval means missed sweeps; those older periods' sub-threshold // tails are forgiven (loudly) rather than billed with multi-period math. @@ -188,10 +231,6 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise 0 && !!sub.stripeCustomerId && !!sub.stripeSubscriptionId + const collectMoney = !enterprise && totalOverage > 0 + if (collectMoney && (!sub.stripeCustomerId || !sub.stripeSubscriptionId)) { + // Claiming the marker here would silently forgive the overage. Defer the + // whole close — the sweep retries every run until the Stripe linkage is + // repaired, and this error is the operator signal. + logger.error('Deferring cycle close: overage due but Stripe identifiers are missing', { + subscriptionId: sub.id, + plan: sub.plan, + totalOverage, + hasStripeCustomerId: !!sub.stripeCustomerId, + hasStripeSubscriptionId: !!sub.stripeSubscriptionId, + }) + return base + } const closeResult = await db.transaction( async ( @@ -471,22 +522,39 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise { if (!sub.periodStart) return + const periodStart = sub.periodStart + + if (hasEnterpriseReportingAnchor(sub)) { + await db.transaction(async (tx) => claimCloseMarker(tx, sub.id, periodStart)) + return + } + const orgScoped = await isSubscriptionOrgScoped(sub) const billingEntity = orgScoped ? ({ type: 'organization', id: sub.referenceId } as const) : ({ type: 'user', id: sub.referenceId } as const) - const range = { from: sub.periodStart, to: sub.periodEnd ?? new Date() } + const range = { from: periodStart, to: sub.periodEnd ?? new Date() } const [usageByUser, copilotByUser] = await Promise.all([ getStampedPeriodRangeUsageCostByUser(billingEntity, range), @@ -532,6 +600,11 @@ export async function writeFinalPeriodBookkeeping(sub: { .set({ departedMemberUsage: '0' }) .where(eq(organization.id, sub.referenceId)) } + // Claim the terminal period's marker with the tracker reset: an in-flight + // sweep close for the elapsed period now fails its under-lock marker + // re-check and rolls back instead of re-billing settled overage. If the + // close already committed, this claim is a guarded no-op. + await claimCloseMarker(tx, sub.id, periodStart) }) } diff --git a/apps/sim/lib/billing/threshold-billing.test.ts b/apps/sim/lib/billing/threshold-billing.test.ts index 43dc758faa6..d0a51b2df10 100644 --- a/apps/sim/lib/billing/threshold-billing.test.ts +++ b/apps/sim/lib/billing/threshold-billing.test.ts @@ -530,6 +530,29 @@ describe('checkAndBillOverageThreshold', () => { expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) + it('aborts settlement when the period advances between preflight and the locked transaction', async () => { + queuePersonalReads() + queueLockedStats(lockedStatsRow()) + mockCalculateSubscriptionOverage.mockResolvedValue(250) + // Preflight passes; the under-lock revalidation sees the rollover. + mockIsSubscriptionCycleCloseCurrent.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + await expect( + checkAndBillOverageThreshold('user-1', undefined, { onError: 'throw' }) + ).rejects.toMatchObject({ + name: ThresholdSettlementError.name, + code: 'concurrent_state_change', + retryable: true, + }) + + expect(mockIsSubscriptionCycleCloseCurrent).toHaveBeenLastCalledWith( + userSubscription.id, + expect.objectContaining({ expectedPeriodStart: userSubscription.periodStart }) + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + it('defers personal settlement while the previous period cycle close is pending', async () => { mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(false) mockCalculateSubscriptionOverage.mockResolvedValue(250) diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index baeb72c4f2f..cd3f80d2923 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -354,6 +354,26 @@ export async function checkAndBillOverageThreshold( return requireSettlementState(options, 'User stats are required for settlement') } + // Revalidate the preflight gate under the tracker lock: a rollover and + // its cycle close (which resets `billedOverageThisPeriod`) can commit + // between the unlocked check and this transaction, and a settlement + // computed from the elapsed period must not land on the new period's + // tracker. + if ( + !(await isSubscriptionCycleCloseCurrent(userSubscription.id, { + executor: tx, + expectedPeriodStart: userSubscription.periodStart, + })) + ) { + logger.debug('Subscription period advanced during threshold settlement; retry later', { + userId, + }) + return retryConcurrentSettlement( + options, + 'Subscription period advanced during threshold settlement' + ) + } + const stats = statsRecords[0] const billedOverageThisPeriod = toNumber(toDecimal(stats.billedOverageThisPeriod)) const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod) @@ -689,6 +709,22 @@ async function checkAndBillOrganizationOverageThreshold( return requireSettlementState(options, 'Owner stats are required for settlement') } + // Same under-lock revalidation as the personal path (see above). + if ( + !(await isSubscriptionCycleCloseCurrent(orgSubscription.id, { + executor: tx, + expectedPeriodStart: orgSubscription.periodStart, + })) + ) { + logger.debug('Organization period advanced during threshold settlement; retry later', { + organizationId, + }) + return retryConcurrentSettlement( + options, + 'Organization period advanced during threshold settlement' + ) + } + const orgLock = await tx .select() .from(organization) diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 483929dfdca..d7fc1f603db 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -258,6 +258,7 @@ export async function handleSubscriptionDeleted( seats?: number | null periodStart?: Date | null periodEnd?: Date | null + metadata?: unknown }, stripeEventId?: string ) { @@ -284,10 +285,12 @@ export async function handleSubscriptionDeleted( if (isEnterprise(subscription.plan)) { await writeFinalPeriodBookkeeping({ + id: subscription.id, plan: subscription.plan, referenceId: subscription.referenceId, periodStart: subscription.periodStart ?? null, periodEnd: subscription.periodEnd ?? null, + metadata: subscription.metadata, }) const dormantResult = await transitionOrganizationToDormantState( @@ -404,10 +407,12 @@ export async function handleSubscriptionDeleted( } await writeFinalPeriodBookkeeping({ + id: subscription.id, plan: subscription.plan, referenceId: subscription.referenceId, periodStart: subscription.periodStart ?? null, periodEnd: subscription.periodEnd ?? null, + metadata: subscription.metadata, }) let restoredProCount = 0 From 313afe5427832e814ef74a4c0fd1ad35968a5ed6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 14:20:28 -0700 Subject: [PATCH 04/14] fix(billing): defer ownerless org closes and make the drift test load-bearing A close with overage due but no owner-role member now defers loudly like the missing-Stripe-identifier case instead of claiming the marker and silently forgiving the money. The stamp-drift test pins the marker before the stamped boundary so only the ledger-stamp lookup can produce the asserted window. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/billing/cycle-close.test.ts | 21 +++++++++++++++++---- apps/sim/lib/billing/cycle-close.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index 8cb40588c16..dd80282c0b2 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -226,20 +226,33 @@ describe('closeElapsedBillingPeriod', () => { }) it('derives the closed window from the ledger period stamps when they drift from calendar math', async () => { - // Rows for the elapsed period are stamped starting Jul 3 (anchor drift); - // the stamped boundary — not periodStart minus one interval — must bound - // the refresh window. + // Rows for the elapsed period are stamped starting Jul 3 (anchor drift) + // while the marker sits at Jul 1: only the stamp lookup can produce the + // Jul 3 bound — calendar math (periodStart minus one interval) would keep + // the window at Jul 1. const stampedPrevStart = new Date('2026-07-03T00:00:00.000Z') queueTableRows(schemaMock.usageLog, [{ start: stampedPrevStart }]) queueOrgCloseReads() - await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: stampedPrevStart })) + await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: PREV_PERIOD_START })) expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith( expect.objectContaining({ periodStart: stampedPrevStart, periodEnd: PERIOD_START }) ) }) + it('defers the close when overage is due but the organization has no owner', async () => { + mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['departed-1', 150]])) + // Member roster has no owner-role row. + queueTableRows(schemaMock.member, [{ userId: 'member-1', role: 'member' }]) + + const result = await closeElapsedBillingPeriod(subRow()) + + expect(result.status).toBe('skipped') + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) + it('includes departed members with billed ledger usage in the refresh actor set', async () => { // 'departed-1' has org-attributed rows in the closed period but no member // row anymore; their refresh consumption must still offset the overage. diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 7957a52c091..d5cdd60c7df 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -314,6 +314,19 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise Date: Tue, 25 Aug 2026 14:23:03 -0700 Subject: [PATCH 05/14] chore(helm): bump chart to 1.6.0 for the billing-cycle-close cron job Co-Authored-By: Claude Opus 5 (1M context) --- helm/sim/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index c60e2c89ce9..aab4d989ccf 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.5.4 +version: 1.6.0 appVersion: "v0.7.44" kubeVersion: ">=1.25.0-0" home: https://sim.ai From 426a76498580b897c78a6550d479a9d45e957a6c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 14:36:29 -0700 Subject: [PATCH 06/14] fix(billing): revalidate the org roster under close locks and align invoice labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close re-reads the member roster inside its transaction and defers on any change, mirroring threshold billing — an owner transfer moves the billed-overage tracker between rows, so a pre-lock roster could settle against the wrong tracker. Invoice labels now use the closed period's end month like every other overage path, and the sweep test's rows are shaped like rows the candidate query can actually return. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/billing/cycle-close.test.ts | 35 ++++++++++++++++++--- apps/sim/lib/billing/cycle-close.ts | 39 ++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index dd80282c0b2..e36c3274ee2 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -110,19 +110,27 @@ function subRow(overrides: Partial> = {}): SubInput { /** * Queues the org close's reads in table order: member roster, in-tx member - * userStats lock, organization credit row, subscription marker re-read, and - * the tracker userStats row. + * userStats lock, organization credit row, subscription marker re-read, the + * under-lock roster revalidation, and the tracker userStats row. */ function queueOrgCloseReads({ members = [{ userId: 'owner-1', role: 'owner' }], orgRow = { creditBalance: '0' }, markerRow = { lastClosedPeriodStart: PREV_PERIOD_START }, + lockedRoster = members, trackerRow = { billedOverageThisPeriod: '0', creditBalance: '0' }, +}: { + members?: { userId: string; role: string }[] + orgRow?: Record + markerRow?: Record + lockedRoster?: { userId: string; role: string }[] + trackerRow?: Record } = {}) { queueTableRows(schemaMock.member, members) queueTableRows(schemaMock.userStats, []) queueTableRows(schemaMock.organization, [orgRow]) queueTableRows(schemaMock.subscription, [markerRow]) + queueTableRows(schemaMock.member, lockedRoster) queueTableRows(schemaMock.userStats, [trackerRow]) } @@ -297,6 +305,21 @@ describe('closeElapsedBillingPeriod', () => { expect(mockRecordAudit).not.toHaveBeenCalled() }) + it('defers when the organization roster changed between preflight and the locked transaction', async () => { + queueOrgCloseReads({ + lockedRoster: [ + { userId: 'owner-1', role: 'member' }, + { userId: 'member-2', role: 'owner' }, + ], + }) + + const result = await closeElapsedBillingPeriod(subRow()) + + expect(result.status).toBe('skipped') + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('no-ops when a concurrent closer already advanced the marker', async () => { queueOrgCloseReads({ markerRow: { lastClosedPeriodStart: PERIOD_START } }) @@ -451,16 +474,18 @@ describe('sweepBillingCycleCloses', () => { mockIsEnterprise.mockReturnValue(false) }) - it('initializes lagging markers and isolates per-subscription failures', async () => { + it('initializes every candidate with a lagging marker', async () => { + // Both rows are shaped like rows the sweep's candidate query can actually + // return: entitled, non-null periodStart, marker lagging (null). queueTableRows(schemaMock.subscription, [ subRow({ id: 'sub-a', lastClosedPeriodStart: null }), - subRow({ id: 'sub-b', lastClosedPeriodStart: null, periodStart: null }), + subRow({ id: 'sub-b', lastClosedPeriodStart: null }), ]) const summary = await sweepBillingCycleCloses() expect(summary.candidates).toBe(2) - expect(summary.initialized).toBe(1) + expect(summary.initialized).toBe(2) expect(summary.failed).toBe(0) }) }) diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index d5cdd60c7df..3138b40c4cb 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -62,6 +62,14 @@ function minusOneInterval(date: Date, billingInterval: string | null): Date { return result } +/** Order-insensitive membership fingerprint for under-lock roster revalidation. */ +function rosterSignature(rows: { userId: string; role: string }[]): string { + return rows + .map((row) => `${row.userId}:${row.role}`) + .sort() + .join('|') +} + function hasEnterpriseReportingAnchor(sub: { plan: string | null; metadata?: unknown }): boolean { return ( isEnterprise(sub.plan) && @@ -299,7 +307,9 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise 0 if (collectMoney && (!sub.stripeCustomerId || !sub.stripeSubscriptionId)) { // Claiming the marker here would silently forgive the overage. Defer the @@ -331,7 +341,11 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise => { + ): Promise<{ + status: 'closed' | 'already-closed' | 'membership-changed' + billed: number + creditsApplied: number + }> => { await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) // Canonical lock order: member userStats rows, then the organization row. @@ -368,6 +382,20 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise Date: Tue, 25 Aug 2026 14:47:55 -0700 Subject: [PATCH 07/14] fix(billing): hold cycle close for a settlement grace after rollover Billing attribution is frozen at run start, so a run straddling a rollover can insert elapsed-period-stamped rows after the period ends. Closing only once the rollover is older than any possible in-flight run guarantees the close's ledger sums are final; the sweep picks the period up on a later run. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/billing/cycle-close.test.ts | 15 +++++++++++++++ apps/sim/lib/billing/cycle-close.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index e36c3274ee2..4fde6ea5780 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -223,6 +223,21 @@ describe('closeElapsedBillingPeriod', () => { expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) }) + it('defers the close inside the settlement grace after a rollover', async () => { + // A run whose frozen attribution predates the rollover could still insert + // elapsed-period rows; the close waits until sums are final. + const result = await closeElapsedBillingPeriod( + subRow({ + periodStart: new Date(Date.now() - 60_000), + lastClosedPeriodStart: new Date(Date.now() - 60_000 - 31 * 24 * 60 * 60 * 1000), + }) + ) + + expect(result.status).toBe('skipped') + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled() + }) + it('defers the close when overage is due but Stripe identifiers are missing', async () => { const result = await closeElapsedBillingPeriod(subRow({ stripeCustomerId: null })) diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 3138b40c4cb..79df55dc27b 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -35,6 +35,18 @@ const logger = createLogger('BillingCycleClose') */ const MIN_CLOSE_INVOICE_DOLLARS = 0.5 +/** + * Settlement grace after a rollover before its elapsed period may close. + * Billing attribution is frozen at run start (the payer is immutable for the + * run), so a run that started just before the rollover can insert rows + * stamped with the elapsed period after it ends. Closing only once the + * rollover is older than any possible in-flight run guarantees the close's + * ledger sums are final — no straggler row is orphaned from the final + * overage or bookkeeping. Non-enterprise execution timeouts are far below + * this bound; the sweep simply picks the period up on a later run. + */ +const CLOSE_SETTLEMENT_GRACE_MS = 60 * 60 * 1000 + type SubscriptionRow = typeof subscriptionTable.$inferSelect export type CycleCloseStatus = 'initialized' | 'current' | 'closed' | 'already-closed' | 'skipped' @@ -199,6 +211,12 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise Date: Tue, 25 Aug 2026 14:56:30 -0700 Subject: [PATCH 08/14] fix(billing): resolve reporting windows through the canonical period resolver The close paths now ask resolveSubscriptionUsagePeriod whether a subscription derives its windows from a reporting anchor instead of re-checking metadata shape locally, so a malformed hand-edited anchor that the resolver rejects (falling back to Stripe bounds) books its Stripe-stamped ledger rows normally instead of skipping bookkeeping. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/billing/cycle-close.test.ts | 8 ++++++- apps/sim/lib/billing/cycle-close.ts | 28 +++++++++++++++--------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index 4fde6ea5780..30d88f9e74a 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -12,6 +12,7 @@ const { mockEnqueueOutboxEvent, mockGetPlanPricing, mockGetPlanTierDollars, + mockResolveSubscriptionUsagePeriod, mockIsEnterprise, mockIsFree, mockRecordAudit, @@ -24,6 +25,7 @@ const { mockEnqueueOutboxEvent: vi.fn(), mockGetPlanPricing: vi.fn(), mockGetPlanTierDollars: vi.fn(), + mockResolveSubscriptionUsagePeriod: vi.fn(), mockIsEnterprise: vi.fn(), mockIsFree: vi.fn(), mockRecordAudit: vi.fn(), @@ -42,7 +44,7 @@ vi.mock('@/lib/billing/core/billing', () => ({ })) vi.mock('@/lib/billing/core/reporting-period', () => ({ - ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY: 'reportingPeriodAnchorDate', + resolveSubscriptionUsagePeriod: mockResolveSubscriptionUsagePeriod, })) vi.mock('@/lib/billing/core/usage-log', () => ({ @@ -141,6 +143,7 @@ describe('closeElapsedBillingPeriod', () => { mockIsSubscriptionOrgScoped.mockResolvedValue(true) mockIsEnterprise.mockReturnValue(false) mockIsFree.mockReturnValue(false) + mockResolveSubscriptionUsagePeriod.mockReturnValue(null) mockGetPlanTierDollars.mockReturnValue(40) mockGetPlanPricing.mockReturnValue({ basePrice: 40 }) mockComputeDailyRefreshConsumed.mockResolvedValue(0) @@ -364,6 +367,7 @@ describe('closeElapsedBillingPeriod', () => { it('only advances the marker for enterprise orgs on reporting anchors', async () => { mockIsEnterprise.mockReturnValue(true) + mockResolveSubscriptionUsagePeriod.mockReturnValue({ source: 'reporting' }) const result = await closeElapsedBillingPeriod( subRow({ plan: 'enterprise', metadata: { reportingPeriodAnchorDate: '2026-05-01' } }) @@ -400,6 +404,7 @@ describe('writeFinalPeriodBookkeeping', () => { resetDbChainMock() mockIsSubscriptionOrgScoped.mockResolvedValue(true) mockIsEnterprise.mockReturnValue(false) + mockResolveSubscriptionUsagePeriod.mockReturnValue(null) mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 25]])) dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) }) @@ -428,6 +433,7 @@ describe('writeFinalPeriodBookkeeping', () => { it('only claims the marker for reporting-anchor enterprise subscriptions', async () => { mockIsEnterprise.mockReturnValue(true) + mockResolveSubscriptionUsagePeriod.mockReturnValue({ source: 'reporting' }) await writeFinalPeriodBookkeeping({ id: 'sub-1', diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 79df55dc27b..7257859d8be 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -9,11 +9,10 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' import { and, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm' import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants' import { computeOrgOverageAmount, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' -import { ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY } from '@/lib/billing/core/reporting-period' +import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period' import { COPILOT_USAGE_SOURCES, getStampedPeriodRangeUsageCostByUser, @@ -82,12 +81,21 @@ function rosterSignature(rows: { userId: string; role: string }[]): string { .join('|') } -function hasEnterpriseReportingAnchor(sub: { plan: string | null; metadata?: unknown }): boolean { - return ( - isEnterprise(sub.plan) && - isRecordLike(sub.metadata) && - typeof sub.metadata[ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY] === 'string' - ) +/** + * Whether this subscription's usage windows derive from an enterprise + * reporting anchor. Asks the same resolver the usage math uses, so a + * malformed anchor (hand-edited Stripe metadata) that the resolver rejects — + * falling back to Stripe bounds — is treated identically here: the ledger + * rows are stamped with Stripe windows, and the close books them normally. + */ +function usesReportingWindows(sub: { + plan?: string | null + billingInterval?: string | null + metadata?: unknown + periodStart?: Date | null + periodEnd?: Date | null +}): boolean { + return resolveSubscriptionUsagePeriod(sub)?.source === 'reporting' } /** @@ -260,7 +268,7 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise claimCloseMarker(tx, sub.id, periodStart)) return } From 6016cc49838fa394e5a0016f068e53308ad8ce9e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 15:02:42 -0700 Subject: [PATCH 09/14] fix(billing): union departed ledger actors in org threshold settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organization threshold billing now reads the period ledger per user and unions the actors holding org-attributed rows with the current roster before computing refresh deductions — the same actor set calculateSubscriptionOverage and the cycle close use — so a departed member's usage cannot be settled without their daily-refresh offset. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/billing/threshold-billing.test.ts | 17 +++++++++++------ apps/sim/lib/billing/threshold-billing.ts | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/billing/threshold-billing.test.ts b/apps/sim/lib/billing/threshold-billing.test.ts index d0a51b2df10..15c426dcd7e 100644 --- a/apps/sim/lib/billing/threshold-billing.test.ts +++ b/apps/sim/lib/billing/threshold-billing.test.ts @@ -10,7 +10,7 @@ const { mockEnqueueOutboxEvent, mockGetEffectiveBillingStatus, mockGetHighestPrioritySubscription, - mockGetBillingPeriodUsageCost, + mockGetBillingPeriodUsageCostByUser, mockGetOrganizationSubscriptionUsable, mockHasUsableSubscriptionAccess, mockIsEnterprise, @@ -26,7 +26,7 @@ const { mockEnqueueOutboxEvent: vi.fn(), mockGetEffectiveBillingStatus: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), - mockGetBillingPeriodUsageCost: vi.fn(), + mockGetBillingPeriodUsageCostByUser: vi.fn(), mockGetOrganizationSubscriptionUsable: vi.fn(), mockHasUsableSubscriptionAccess: vi.fn(), mockIsEnterprise: vi.fn(), @@ -60,7 +60,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ })) vi.mock('@/lib/billing/core/usage-log', () => ({ - getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost, + getBillingPeriodUsageCostByUser: mockGetBillingPeriodUsageCostByUser, })) vi.mock('@/lib/billing/cycle-close', () => ({ @@ -184,7 +184,7 @@ describe('checkAndBillOverageThreshold', () => { mockIsFree.mockReturnValue(false) mockIsEnterprise.mockReturnValue(false) mockIsOrgScopedSubscription.mockReturnValue(false) - mockGetBillingPeriodUsageCost.mockResolvedValue(0) + mockGetBillingPeriodUsageCostByUser.mockResolvedValue(new Map()) mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(true) }) @@ -609,7 +609,12 @@ describe('checkAndBillOverageThreshold', () => { mockIsOrgScopedSubscription.mockReturnValue(true) mockIsOrganizationBillingBlocked.mockResolvedValue(false) mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) - mockGetBillingPeriodUsageCost.mockResolvedValue(350) + mockGetBillingPeriodUsageCostByUser.mockResolvedValue( + new Map([ + ['owner-1', 300], + ['departed-1', 50], + ]) + ) queueOrgReads() mockComputeOrgOverageAmount.mockResolvedValue({ totalOverage: 250, @@ -626,7 +631,7 @@ describe('checkAndBillOverageThreshold', () => { periodEnd: new Date('2026-06-01T00:00:00.000Z'), organizationId: userSubscription.referenceId, pooledLedgerUsage: 350, - memberIds: ['owner-1'], + memberIds: ['owner-1', 'departed-1'], }) expect(dbChainMockFns.transaction).toHaveBeenCalled() expect(mockComputeOrgOverageAmount.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index cd3f80d2923..b7f65ec1074 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -13,7 +13,7 @@ import { getHighestPrioritySubscription, getOrganizationSubscriptionUsable, } from '@/lib/billing/core/subscription' -import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' +import { type BillingEntity, getBillingPeriodUsageCostByUser } from '@/lib/billing/core/usage-log' import { isSubscriptionCycleCloseCurrent } from '@/lib/billing/cycle-close' import { isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { @@ -615,13 +615,20 @@ async function checkAndBillOrganizationOverageThreshold( ownerId: usageSnapshot.ownerId, }) - const ledgerUsage = + const orgUsageByUser = orgSubscription.periodStart && orgSubscription.periodEnd - ? await getBillingPeriodUsageCost( + ? await getBillingPeriodUsageCostByUser( { type: 'organization', id: organizationId }, { start: orgSubscription.periodStart, end: orgSubscription.periodEnd } ) - : 0 + : new Map() + let ledgerUsage = 0 + for (const cost of orgUsageByUser.values()) ledgerUsage += cost + // Union current members with every actor holding org-attributed rows this + // period: a member who departed mid-period still bills here, so their + // daily-refresh consumption must offset the overage too — same actor set + // as `calculateSubscriptionOverage` and the cycle close. + const overageActorIds = [...new Set([...usageSnapshot.memberIds, ...orgUsageByUser.keys()])] const { totalOverage: currentOverage, @@ -634,7 +641,7 @@ async function checkAndBillOrganizationOverageThreshold( periodEnd: orgSubscription.periodEnd ?? null, organizationId, pooledLedgerUsage: ledgerUsage, - memberIds: usageSnapshot.memberIds, + memberIds: overageActorIds, }) if (currentOverage < threshold) { From a3fc0919dd3fb9f2576798a193095c02cfaf1984 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 15:19:19 -0700 Subject: [PATCH 10/14] fix(billing): claim the terminal period before deletion settlement and delete vestigial refresh bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscription deletion now claims the close marker from the fresh subscription row before computing or charging final overage, serializing with the cycle-close sweep so both paths can never bill the same period — an in-flight close fails its guarded claim and rolls back, and the deletion settles against the row's real period instead of a possibly stale webhook payload. The per-user refresh bounds machinery is deleted outright: its only source was proPeriodCostSnapshotAt, which this PR stopped writing, and ledger entity stamps already scope refresh to org-attributed rows — a joiner's pre-join usage is user-stamped and can never enter the org refresh scan, while a departed member's org-stamped rows participate exactly like a current member's. Co-Authored-By: Claude Opus 5 (1M context) --- .../calculations/usage-monitor.test.ts | 10 +- .../lib/billing/calculations/usage-monitor.ts | 7 +- apps/sim/lib/billing/core/billing.test.ts | 4 - apps/sim/lib/billing/core/billing.ts | 7 +- apps/sim/lib/billing/core/usage.test.ts | 1 - apps/sim/lib/billing/core/usage.ts | 21 +-- .../lib/billing/credits/daily-refresh.test.ts | 22 --- apps/sim/lib/billing/credits/daily-refresh.ts | 143 +++--------------- apps/sim/lib/billing/cycle-close.test.ts | 42 +++-- apps/sim/lib/billing/cycle-close.ts | 56 ++++--- apps/sim/lib/billing/webhooks/subscription.ts | 24 ++- 11 files changed, 112 insertions(+), 225 deletions(-) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index 292c8b76d59..5ddfb5f6554 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -12,7 +12,6 @@ const { mockGetUserUsageLimit, mockIsOrganizationBillingBlocked, mockComputeBillingPeriodUsageWithDailyRefresh, - mockGetOrgMemberRefreshBounds, } = vi.hoisted(() => ({ mockGetBillingPeriodUsageCost: vi.fn(), mockGetOrgMemberUsageForBillingPeriod: vi.fn(), @@ -21,7 +20,6 @@ const { mockGetUserUsageLimit: vi.fn(), mockIsOrganizationBillingBlocked: vi.fn(), mockComputeBillingPeriodUsageWithDailyRefresh: vi.fn(), - mockGetOrgMemberRefreshBounds: vi.fn(), })) vi.mock('@/lib/billing/organizations/member-limits', () => ({ @@ -46,7 +44,6 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ vi.mock('@/lib/billing/credits/daily-refresh', () => ({ computeBillingPeriodUsageWithDailyRefresh: mockComputeBillingPeriodUsageWithDailyRefresh, - getOrgMemberRefreshBounds: mockGetOrgMemberRefreshBounds, })) import { @@ -74,7 +71,6 @@ describe('checkUsageStatus', () => { ledgerUsage: 125, refreshConsumed: 25, }) - mockGetOrgMemberRefreshBounds.mockResolvedValue({}) }) it('reads reporting-period organization usage without loading the member roster', async () => { @@ -206,10 +202,9 @@ describe('checkUsageStatus', () => { expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled() }) - it('combines paid organization ledger usage with bounded member refresh', async () => { + it('combines paid organization ledger usage with member refresh', async () => { const periodStart = new Date('2026-06-01T00:00:00.000Z') const periodEnd = new Date('2026-07-01T00:00:00.000Z') - const userStart = new Date('2026-06-10T00:00:00.000Z') const subscription = { referenceId: 'org-1', plan: 'team', @@ -222,7 +217,6 @@ describe('checkUsageStatus', () => { memberIds: ['user-1', 'user-2'], lastPeriodCost: 0, }) - mockGetOrgMemberRefreshBounds.mockResolvedValue({ 'user-2': { userStart } }) mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValue({ ledgerUsage: 100, refreshConsumed: 10, @@ -246,7 +240,6 @@ describe('checkUsageStatus', () => { refreshPeriodEnd: periodEnd, planDollars: expect.any(Number), seats: 2, - userBounds: { 'user-2': { userStart } }, }) expect(mockGetBillingPeriodUsageCost).not.toHaveBeenCalled() }) @@ -271,7 +264,6 @@ describe('checkUsageStatus', () => { expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1) expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled() - expect(mockGetOrgMemberRefreshBounds).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 52057463709..139a10d8ecd 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -19,10 +19,7 @@ import { type UsageQueryPeriod, } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { - computeBillingPeriodUsageWithDailyRefresh, - getOrgMemberRefreshBounds, -} from '@/lib/billing/credits/daily-refresh' +import { computeBillingPeriodUsageWithDailyRefresh } from '@/lib/billing/credits/daily-refresh' import { getOrgMemberUsageForBillingPeriod, getOrgMemberUsageLimit, @@ -78,7 +75,6 @@ async function computePooledOrgUsage( return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) } - const userBounds = await getOrgMemberRefreshBounds(organizationId, sub.periodStart) const { ledgerUsage, refreshConsumed } = await computeBillingPeriodUsageWithDailyRefresh({ billingEntity: { type: 'organization', id: organizationId }, billingPeriod, @@ -87,7 +83,6 @@ async function computePooledOrgUsage( refreshPeriodEnd: sub.periodEnd ?? null, planDollars, seats: sub.seats || 1, - userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, }) return Math.max(0, ledgerUsage - refreshConsumed) diff --git a/apps/sim/lib/billing/core/billing.test.ts b/apps/sim/lib/billing/core/billing.test.ts index 718bf1cd3f3..e9f8b18d75c 100644 --- a/apps/sim/lib/billing/core/billing.test.ts +++ b/apps/sim/lib/billing/core/billing.test.ts @@ -12,7 +12,6 @@ const { mockGetBillingPeriodUsageCostWithSourceSubset, mockGetHighestPriorityPersonalSubscription, mockGetHighestPrioritySubscription, - mockGetOrgMemberRefreshBounds, mockResolveBillingInterval, } = vi.hoisted(() => ({ mockComputeDailyRefreshConsumed: vi.fn(), @@ -22,7 +21,6 @@ const { mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(), mockGetHighestPriorityPersonalSubscription: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), - mockGetOrgMemberRefreshBounds: vi.fn(), mockResolveBillingInterval: vi.fn(), })) @@ -47,7 +45,6 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ vi.mock('@/lib/billing/credits/daily-refresh', () => ({ computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed, - getOrgMemberRefreshBounds: mockGetOrgMemberRefreshBounds, })) import { calculateSubscriptionOverage, getPersonalBillingSummary } from '@/lib/billing/core/billing' @@ -129,7 +126,6 @@ describe('getPersonalBillingSummary', () => { describe('calculateSubscriptionOverage', () => { beforeEach(() => { vi.clearAllMocks() - mockGetOrgMemberRefreshBounds.mockResolvedValue({}) mockComputeDailyRefreshConsumed.mockResolvedValue(0) }) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 5c459339af8..b62d3ad5e9c 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -13,10 +13,7 @@ import { getBillingPeriodUsageCostByUser, getBillingPeriodUsageCostWithSourceSubset, } from '@/lib/billing/core/usage-log' -import { - computeDailyRefreshConsumed, - getOrgMemberRefreshBounds, -} from '@/lib/billing/credits/daily-refresh' +import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, @@ -139,14 +136,12 @@ export async function computeOrgOverageAmount(params: { 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, billingEntity: { type: 'organization', id: params.organizationId }, }) } diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index 3d98e15e3c5..d0bca9f4c7c 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -60,7 +60,6 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ vi.mock('@/lib/billing/credits/daily-refresh', () => ({ computeDailyRefreshConsumed: vi.fn(), - getOrgMemberRefreshBounds: vi.fn(), })) const { diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 86b2684b43a..7cad5c1977e 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -23,10 +23,7 @@ import { resolveSubscriptionUsagePeriod, } from '@/lib/billing/core/reporting-period' import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' -import { - computeDailyRefreshConsumed, - getOrgMemberRefreshBounds, -} from '@/lib/billing/credits/daily-refresh' +import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers' import { canEditUsageLimit, @@ -284,11 +281,6 @@ export async function getResolvedUserUsageData( if (planDollars > 0) { if (orgScoped) { if (orgMemberIds.length > 0) { - const userBounds = await getOrgMemberRefreshBounds( - subscription.referenceId, - billingPeriodStart, - executor - ) dailyRefreshConsumed = await computeDailyRefreshConsumed( { userIds: orgMemberIds, @@ -296,7 +288,6 @@ export async function getResolvedUserUsageData( periodEnd: billingPeriodEnd, planDollars, seats: subscription.seats || 1, - userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, billingEntity: { type: 'organization', id: subscription.referenceId }, }, executor @@ -713,15 +704,6 @@ export async function getEffectiveCurrentPeriodCost( const planDollars = getPlanTierDollars(subscription.plan) if (planDollars <= 0) return rawCost - const userBounds = - orgScoped && subscription.periodStart - ? await getOrgMemberRefreshBounds( - subscription.referenceId, - subscription.periodStart, - executor - ) - : {} - const refreshConsumed = await computeDailyRefreshConsumed( { userIds: refreshUserIds, @@ -729,7 +711,6 @@ export async function getEffectiveCurrentPeriodCost( periodEnd: subscription.periodEnd ?? null, planDollars, seats: subscription.seats || 1, - userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined, billingEntity: orgScoped && subscription ? { type: 'organization', id: subscription.referenceId } diff --git a/apps/sim/lib/billing/credits/daily-refresh.test.ts b/apps/sim/lib/billing/credits/daily-refresh.test.ts index 3a97f5dd289..22aca7064ee 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.test.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.test.ts @@ -89,28 +89,6 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => { reportingEnd ) }) - - it('preserves a bounded user refresh window without narrowing the ledger total', async () => { - const userStart = new Date('2026-03-10T00:00:00.000Z') - const userEnd = new Date('2026-03-20T00:00:00.000Z') - dbChainMockFns.groupBy.mockResolvedValueOnce([ - { ledgerTotal: '30.00', refreshDayTotal: '0.25' }, - ]) - - await computeBillingPeriodUsageWithDailyRefresh({ - billingEntity: { type: 'organization', id: 'org-1' }, - billingPeriod: { start: periodStart, end: periodEnd }, - userIds: ['bounded-user'], - refreshPeriodStart: periodStart, - refreshPeriodEnd: periodEnd, - planDollars: 25, - userBounds: { 'bounded-user': { userStart, userEnd } }, - }) - - expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.usageLog.userId, 'bounded-user') - expect(drizzleOrmMock.gte).toHaveBeenCalledWith(schemaMock.usageLog.createdAt, userStart) - expect(drizzleOrmMock.lt).toHaveBeenCalledWith(schemaMock.usageLog.createdAt, userEnd) - }) }) describe('computeDailyRefreshConsumed', () => { diff --git a/apps/sim/lib/billing/credits/daily-refresh.ts b/apps/sim/lib/billing/credits/daily-refresh.ts index 015a15f0dbe..c11d65c6b8a 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.ts @@ -12,9 +12,9 @@ */ import { db } from '@sim/db' -import { member, usageLog, userStats } from '@sim/db/schema' +import { usageLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, gte, inArray, isNull, lt, lte, or, 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' import type { BillingEntity, UsageQueryPeriod } from '@/lib/billing/core/usage-log' import type { DbClient } from '@/lib/db/types' @@ -24,17 +24,6 @@ const logger = createLogger('DailyRefresh') const MS_PER_DAY = 86_400_000 const MAX_BILLING_PERIOD_DAYS = 370 -/** - * 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 -} - interface BillingPeriodUsageWithDailyRefreshParams { billingEntity: BillingEntity billingPeriod: UsageQueryPeriod @@ -43,7 +32,6 @@ interface BillingPeriodUsageWithDailyRefreshParams { refreshPeriodEnd?: Date | null planDollars: number seats?: number - userBounds?: Record } /** @@ -65,44 +53,18 @@ export async function computeBillingPeriodUsageWithDailyRefresh( refreshPeriodEnd, planDollars, seats = 1, - userBounds, } = params const now = new Date() const cap = refreshPeriodEnd && refreshPeriodEnd < now ? refreshPeriodEnd : now const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats - const eligibleUserIds = new Set(userIds) - const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds - const boundedClauses = userBounds - ? Object.entries(userBounds).flatMap(([userId, bounds]) => { - if (!eligibleUserIds.has(userId)) return [] - const effectiveStart = - bounds.userStart && bounds.userStart > refreshPeriodStart - ? bounds.userStart - : refreshPeriodStart - 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 refreshUserFilters = cap > refreshPeriodStart ? [ - ...(unboundedUsers.length > 0 - ? [ - and( - inArray(usageLog.userId, unboundedUsers), - gte(usageLog.createdAt, refreshPeriodStart), - lt(usageLog.createdAt, cap) - ), - ] - : []), - ...boundedClauses, + and( + inArray(usageLog.userId, userIds), + gte(usageLog.createdAt, refreshPeriodStart), + lt(usageLog.createdAt, cap) + ), ] : [] const refreshFilter = @@ -188,20 +150,11 @@ export async function computeDailyRefreshConsumed( periodEnd?: Date | null planDollars: number seats?: number - userBounds?: Record billingEntity?: { type: 'user' | 'organization'; id: string } }, executor: DbClient = db ): Promise { - const { - userIds, - periodStart, - periodEnd, - planDollars, - seats = 1, - userBounds, - billingEntity, - } = params + const { userIds, periodStart, periodEnd, planDollars, seats = 1, billingEntity } = params if (planDollars <= 0 || userIds.length === 0) return 0 @@ -215,7 +168,6 @@ export async function computeDailyRefreshConsumed( 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 billingEntityFilter = billingEntity ? and( eq(usageLog.billingEntityType, billingEntity.type), @@ -224,38 +176,14 @@ export async function computeDailyRefreshConsumed( ) : undefined - 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), - billingEntityFilter, - gte(usageLog.createdAt, effectiveStart), - lt(usageLog.createdAt, effectiveEnd) - ), - ] - }) - : [] - - const rowFilters = - unboundedUsers.length > 0 - ? [ - and( - inArray(usageLog.userId, unboundedUsers), - billingEntityFilter, - gte(usageLog.createdAt, periodStart), - lt(usageLog.createdAt, cap) - ), - ...boundedClauses, - ] - : boundedClauses - - if (rowFilters.length === 0) return 0 + const rowFilters = [ + and( + inArray(usageLog.userId, userIds), + billingEntityFilter, + gte(usageLog.createdAt, periodStart), + lt(usageLog.createdAt, cap) + ), + ] const rows = await executor .select({ @@ -281,7 +209,6 @@ export async function computeDailyRefreshConsumed( days: dayCount, dailyRefreshDollars, totalConsumed, - hasUserBounds: Boolean(userBounds), }) return totalConsumed @@ -309,6 +236,9 @@ export async function computeOrganizationDailyRefreshConsumed( } const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats + // Entity/period stamps fully scope the rows: every org-attributed row — + // including a departed member's — participates in the org's refresh, and a + // member's pre-join rows are user-stamped so they can never appear here. const rows = await executor .select({ dayIndex: @@ -318,23 +248,13 @@ export async function computeOrganizationDailyRefreshConsumed( dayTotal: sum(usageLog.cost).as('day_total'), }) .from(usageLog) - .innerJoin( - member, - and(eq(member.userId, usageLog.userId), eq(member.organizationId, organizationId)) - ) - .leftJoin(userStats, eq(userStats.userId, member.userId)) .where( and( eq(usageLog.billingEntityType, 'organization'), eq(usageLog.billingEntityId, organizationId), eq(usageLog.billingPeriodStart, periodStart), gte(usageLog.createdAt, periodStart), - lt(usageLog.createdAt, cap), - or( - isNull(userStats.proPeriodCostSnapshotAt), - lte(userStats.proPeriodCostSnapshotAt, periodStart), - gte(usageLog.createdAt, userStats.proPeriodCostSnapshotAt) - ) + lt(usageLog.createdAt, cap) ) ) .groupBy(sql`day_index`) @@ -351,26 +271,3 @@ export async function computeOrganizationDailyRefreshConsumed( export function getDailyRefreshDollars(planDollars: number): number { return planDollars * DAILY_REFRESH_RATE } - -export async function getOrgMemberRefreshBounds( - organizationId: string, - periodStart: Date, - executor: DbClient = db -): Promise> { - const rows = await executor - .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/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index 30d88f9e74a..0a83ffad1da 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -82,6 +82,7 @@ vi.mock('@/lib/posthog/server', () => ({ })) import { + claimTerminalPeriod, closeElapsedBillingPeriod, isSubscriptionCycleCloseCurrent, sweepBillingCycleCloses, @@ -409,7 +410,7 @@ describe('writeFinalPeriodBookkeeping', () => { dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) }) - it('resets trackers, writes last-period sums, and claims the terminal marker in one transaction', async () => { + it('resets trackers and writes last-period sums in one transaction', async () => { queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) await writeFinalPeriodBookkeeping({ @@ -425,13 +426,9 @@ describe('writeFinalPeriodBookkeeping', () => { (call) => (call[0] as Record).billedOverageThisPeriod === '0' ) expect(bookkeepingSet).toBeDefined() - const markerSet = dbChainMockFns.set.mock.calls.find( - (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date - ) - expect(markerSet).toBeDefined() }) - it('only claims the marker for reporting-anchor enterprise subscriptions', async () => { + it('is a no-op for reporting-anchor enterprise subscriptions', async () => { mockIsEnterprise.mockReturnValue(true) mockResolveSubscriptionUsagePeriod.mockReturnValue({ source: 'reporting' }) @@ -445,14 +442,39 @@ describe('writeFinalPeriodBookkeeping', () => { }) expect(mockGetStampedPeriodRangeUsageCostByUser).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) +}) + +describe('claimTerminalPeriod', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) + }) + + it('claims the marker from the fresh row period and returns it for settlement', async () => { + queueTableRows(schemaMock.subscription, [ + { periodStart: PERIOD_START, periodEnd: new Date('2026-09-01T00:00:00.000Z') }, + ]) + + const terminal = await claimTerminalPeriod('sub-1') + + expect(terminal.periodStart).toEqual(PERIOD_START) const markerSet = dbChainMockFns.set.mock.calls.find( (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date ) expect(markerSet).toBeDefined() - const bookkeepingSet = dbChainMockFns.set.mock.calls.find( - (call) => (call[0] as Record).billedOverageThisPeriod === '0' - ) - expect(bookkeepingSet).toBeUndefined() + }) + + it('returns nulls without claiming when the subscription has no period', async () => { + queueTableRows(schemaMock.subscription, [{ periodStart: null, periodEnd: null }]) + + const terminal = await claimTerminalPeriod('sub-1') + + expect(terminal).toEqual({ periodStart: null, periodEnd: null }) + expect(dbChainMockFns.set).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 7257859d8be..3926e046bc8 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -144,6 +144,37 @@ export async function isSubscriptionCycleCloseCurrent( ) } +/** + * Claim the terminal period for a subscription that is being deleted, BEFORE + * the deletion handler computes and charges final overage. Reads the + * subscription row fresh (webhook payloads can be stale across a rollover) + * and advances the close marker to its current `periodStart` in one + * transaction, serializing with the sweep on the subscription row: an + * in-flight sweep close then fails its guarded marker claim and rolls back — + * including its outbox invoice — so deletion and sweep can never both bill + * the same period. Returns the fresh period bounds for the deletion flow to + * settle against. + */ +export async function claimTerminalPeriod( + subscriptionId: string +): Promise<{ periodStart: Date | null; periodEnd: Date | null }> { + return db.transaction(async (tx) => { + const [row] = await tx + .select({ + periodStart: subscriptionTable.periodStart, + periodEnd: subscriptionTable.periodEnd, + }) + .from(subscriptionTable) + .where(eq(subscriptionTable.id, subscriptionId)) + .for('update') + .limit(1) + + if (!row?.periodStart) return { periodStart: null, periodEnd: null } + await claimCloseMarker(tx, subscriptionId, row.periodStart) + return { periodStart: row.periodStart, periodEnd: row.periodEnd } + }) +} + /** * Advance the durable close marker to `periodStart`, guarded so concurrent * closers and replays collapse to one winner. Returns false when another @@ -597,16 +628,13 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise claimCloseMarker(tx, sub.id, periodStart)) - return - } + if (usesReportingWindows(sub)) return const orgScoped = await isSubscriptionOrgScoped(sub) const billingEntity = orgScoped @@ -674,11 +699,6 @@ export async function writeFinalPeriodBookkeeping(sub: { .set({ departedMemberUsage: '0' }) .where(eq(organization.id, sub.referenceId)) } - // Claim the terminal period's marker with the tracker reset: an in-flight - // sweep close for the elapsed period now fails its under-lock marker - // re-check and rolls back instead of re-billing settled overage. If the - // close already committed, this claim is a guarded no-op. - await claimCloseMarker(tx, sub.id, periodStart) }) } diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index d7fc1f603db..220908a1923 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, ne } from 'drizzle-orm' import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' -import { writeFinalPeriodBookkeeping } from '@/lib/billing/cycle-close' +import { claimTerminalPeriod, writeFinalPeriodBookkeeping } from '@/lib/billing/cycle-close' import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' @@ -280,7 +280,21 @@ export async function handleSubscriptionDeleted( 'subscription-deleted', idempotencyIdentifier, async () => { - const totalOverage = await calculateSubscriptionOverage(subscription) + // Claim the terminal period BEFORE computing or charging: this reads + // the row's fresh period (webhook payloads can be stale across a + // rollover) and serializes with the cycle-close sweep — an in-flight + // close fails its guarded marker claim and rolls back, so both paths + // can never bill the same period. + const terminal = await claimTerminalPeriod(subscription.id) + const settlementPeriod = { + periodStart: terminal.periodStart ?? subscription.periodStart ?? null, + periodEnd: terminal.periodEnd ?? subscription.periodEnd ?? null, + } + + const totalOverage = await calculateSubscriptionOverage({ + ...subscription, + ...settlementPeriod, + }) const stripe = requireStripeClient() if (isEnterprise(subscription.plan)) { @@ -288,8 +302,7 @@ export async function handleSubscriptionDeleted( id: subscription.id, plan: subscription.plan, referenceId: subscription.referenceId, - periodStart: subscription.periodStart ?? null, - periodEnd: subscription.periodEnd ?? null, + ...settlementPeriod, metadata: subscription.metadata, }) @@ -410,8 +423,7 @@ export async function handleSubscriptionDeleted( id: subscription.id, plan: subscription.plan, referenceId: subscription.referenceId, - periodStart: subscription.periodStart ?? null, - periodEnd: subscription.periodEnd ?? null, + ...settlementPeriod, metadata: subscription.metadata, }) From f907385d48fac967604c5c857376b91a292f80b8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 15:48:19 -0700 Subject: [PATCH 11/14] fix(billing): scope daily refresh by entity stamps and close lagging periods before deletion settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh actor-list threading (userIds + departed-actor unions) violated the entity-stamp principle the ledger sums already follow: org-attributed rows from departed members counted in pooled usage but never consumed refresh on the monitor/resolved-usage paths. Daily refresh now scopes rows by the same write-time entity and period stamps as the ledger — no actor list anywhere — which deletes the unions, the rollup memberIds, and the org-specific refresh variant. Deletion settlement now closes any elapsed-but-unclosed period (grace bypassed — no later sweep revisits a canceled sub) before claiming the terminal period, so a deletion racing the sweep can no longer jump the marker past an unsettled period and silently forgive its final overage. Co-Authored-By: Claude Fable 5 --- .../calculations/usage-monitor.test.ts | 38 +--- .../lib/billing/calculations/usage-monitor.ts | 13 +- apps/sim/lib/billing/core/billing.test.ts | 36 ++-- apps/sim/lib/billing/core/billing.ts | 32 +--- apps/sim/lib/billing/core/organization.ts | 6 +- apps/sim/lib/billing/core/usage.ts | 107 ++++-------- .../lib/billing/credits/daily-refresh.test.ts | 74 ++++---- apps/sim/lib/billing/credits/daily-refresh.ts | 162 +++++------------- apps/sim/lib/billing/cycle-close.test.ts | 70 +++++++- apps/sim/lib/billing/cycle-close.ts | 80 +++++++-- .../sim/lib/billing/threshold-billing.test.ts | 17 +- apps/sim/lib/billing/threshold-billing.ts | 18 +- apps/sim/lib/billing/webhooks/subscription.ts | 23 ++- 13 files changed, 305 insertions(+), 371 deletions(-) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index 5ddfb5f6554..a11905e5980 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -8,7 +8,6 @@ const { mockGetBillingPeriodUsageCost, mockGetOrgMemberUsageForBillingPeriod, mockGetOrgMemberUsageLimit, - mockGetOrgMemberBillingRollup, mockGetUserUsageLimit, mockIsOrganizationBillingBlocked, mockComputeBillingPeriodUsageWithDailyRefresh, @@ -16,7 +15,6 @@ const { mockGetBillingPeriodUsageCost: vi.fn(), mockGetOrgMemberUsageForBillingPeriod: vi.fn(), mockGetOrgMemberUsageLimit: vi.fn(), - mockGetOrgMemberBillingRollup: vi.fn(), mockGetUserUsageLimit: vi.fn(), mockIsOrganizationBillingBlocked: vi.fn(), mockComputeBillingPeriodUsageWithDailyRefresh: vi.fn(), @@ -31,10 +29,9 @@ vi.mock('@/lib/billing/core/access', () => ({ isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked, })) -// core/usage pulls in the email-rendering chain at import; stub the two symbols +// core/usage pulls in the email-rendering chain at import; stub the symbol // usage-monitor imports from it so the module loads in a node test env. vi.mock('@/lib/billing/core/usage', () => ({ - getOrgMemberBillingRollup: mockGetOrgMemberBillingRollup, getUserUsageLimit: mockGetUserUsageLimit, })) @@ -106,7 +103,6 @@ describe('checkUsageStatus', () => { { type: 'organization', id: 'org-1' }, billingPeriod ) - expect(mockGetOrgMemberBillingRollup).not.toHaveBeenCalled() }) it('reads paid personal ledger usage and refresh from one snapshot', async () => { @@ -128,7 +124,6 @@ describe('checkUsageStatus', () => { expect(mockComputeBillingPeriodUsageWithDailyRefresh).toHaveBeenCalledWith({ billingEntity: { type: 'user', id: 'user-1' }, billingPeriod: { start: periodStart, end: periodEnd }, - userIds: ['user-1'], refreshPeriodStart: periodStart, refreshPeriodEnd: periodEnd, planDollars: 20, @@ -202,7 +197,7 @@ describe('checkUsageStatus', () => { expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled() }) - it('combines paid organization ledger usage with member refresh', async () => { + it('combines paid organization ledger usage with entity-scoped refresh — no roster read', async () => { const periodStart = new Date('2026-06-01T00:00:00.000Z') const periodEnd = new Date('2026-07-01T00:00:00.000Z') const subscription = { @@ -213,10 +208,6 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - mockGetOrgMemberBillingRollup.mockResolvedValue({ - memberIds: ['user-1', 'user-2'], - lastPeriodCost: 0, - }) mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValue({ ledgerUsage: 100, refreshConsumed: 10, @@ -228,6 +219,8 @@ describe('checkUsageStatus', () => { organizationId: 'org-1', }) + // Refresh is scoped by the entity stamps alone, so departed members' + // org-attributed rows participate identically to current members'. expect(mockComputeBillingPeriodUsageWithDailyRefresh).toHaveBeenCalledWith({ billingEntity: { type: 'organization', id: 'org-1' }, billingPeriod: expect.objectContaining({ @@ -235,7 +228,6 @@ describe('checkUsageStatus', () => { end: periodEnd, source: 'stripe', }), - userIds: ['user-1', 'user-2'], refreshPeriodStart: periodStart, refreshPeriodEnd: periodEnd, planDollars: expect.any(Number), @@ -243,28 +235,6 @@ describe('checkUsageStatus', () => { }) expect(mockGetBillingPeriodUsageCost).not.toHaveBeenCalled() }) - - it('returns ledger usage without refresh when an organization has no members', async () => { - const periodStart = new Date('2026-06-01T00:00:00.000Z') - const periodEnd = new Date('2026-07-01T00:00:00.000Z') - const subscription = { - referenceId: 'org-1', - plan: 'team', - status: 'active', - seats: 1, - periodStart, - periodEnd, - } - mockGetOrgMemberBillingRollup.mockResolvedValue({ memberIds: [], lastPeriodCost: 0 }) - - await expect(checkUsageStatus('user-1', subscription)).resolves.toMatchObject({ - currentUsage: 125, - scope: 'organization', - }) - - expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledTimes(1) - expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled() - }) }) describe('checkServerSideUsageLimits', () => { diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index 139a10d8ecd..b8387fd28e0 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -7,11 +7,7 @@ import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period' -import { - getOrgMemberBillingRollup, - getUserUsageLimit, - type UsageLimitSubscription, -} from '@/lib/billing/core/usage' +import { getUserUsageLimit, type UsageLimitSubscription } from '@/lib/billing/core/usage' import { type BillingContext, type BillingEntity, @@ -70,15 +66,9 @@ async function computePooledOrgUsage( return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) } - const { memberIds } = await getOrgMemberBillingRollup(organizationId) - if (memberIds.length === 0) { - return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) - } - const { ledgerUsage, refreshConsumed } = await computeBillingPeriodUsageWithDailyRefresh({ billingEntity: { type: 'organization', id: organizationId }, billingPeriod, - userIds: memberIds, refreshPeriodStart: sub.periodStart, refreshPeriodEnd: sub.periodEnd ?? null, planDollars, @@ -151,7 +141,6 @@ export async function checkUsageStatus( const usage = await computeBillingPeriodUsageWithDailyRefresh({ billingEntity: { type: 'user', id: userId }, billingPeriod, - userIds: [userId], refreshPeriodStart: sub.periodStart, refreshPeriodEnd: sub.periodEnd ?? null, planDollars, diff --git a/apps/sim/lib/billing/core/billing.test.ts b/apps/sim/lib/billing/core/billing.test.ts index e9f8b18d75c..4ee6c354483 100644 --- a/apps/sim/lib/billing/core/billing.test.ts +++ b/apps/sim/lib/billing/core/billing.test.ts @@ -8,7 +8,6 @@ const { mockComputeDailyRefreshConsumed, mockEnsureUserStatsExists, mockGetBillingPeriodUsageCost, - mockGetBillingPeriodUsageCostByUser, mockGetBillingPeriodUsageCostWithSourceSubset, mockGetHighestPriorityPersonalSubscription, mockGetHighestPrioritySubscription, @@ -17,7 +16,6 @@ const { mockComputeDailyRefreshConsumed: vi.fn(), mockEnsureUserStatsExists: vi.fn(), mockGetBillingPeriodUsageCost: vi.fn(), - mockGetBillingPeriodUsageCostByUser: vi.fn(), mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(), mockGetHighestPriorityPersonalSubscription: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), @@ -39,7 +37,6 @@ vi.mock('@/lib/billing/core/usage', () => ({ vi.mock('@/lib/billing/core/usage-log', () => ({ COPILOT_USAGE_SOURCES: ['copilot'], getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost, - getBillingPeriodUsageCostByUser: mockGetBillingPeriodUsageCostByUser, getBillingPeriodUsageCostWithSourceSubset: mockGetBillingPeriodUsageCostWithSourceSubset, })) @@ -129,17 +126,12 @@ describe('calculateSubscriptionOverage', () => { mockComputeDailyRefreshConsumed.mockResolvedValue(0) }) - it('includes departed ledger actors in the org refresh deduction', async () => { + it('bills the pooled org ledger with entity-scoped refresh — no roster read', async () => { queueTableRows(schemaMock.organization, [{ id: 'org-1' }]) // isSubscriptionOrgScoped - queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) // current members - mockGetBillingPeriodUsageCostByUser.mockResolvedValue( - new Map([ - ['owner-1', 100], - ['departed-1', 60], - ]) - ) + // Pooled ledger sum includes departed members' org-stamped rows. + mockGetBillingPeriodUsageCost.mockResolvedValue(160) - await calculateSubscriptionOverage({ + const overage = await calculateSubscriptionOverage({ id: 'sub-1', plan: 'team', referenceId: 'org-1', @@ -148,10 +140,22 @@ describe('calculateSubscriptionOverage', () => { periodEnd: new Date('2026-08-01T00:00:00.000Z'), }) - expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith( - expect.objectContaining({ - userIds: expect.arrayContaining(['owner-1', 'departed-1']), - }) + expect(mockGetBillingPeriodUsageCost).toHaveBeenCalledWith( + { type: 'organization', id: 'org-1' }, + { + start: new Date('2026-07-01T00:00:00.000Z'), + end: new Date('2026-08-01T00:00:00.000Z'), + } ) + // Refresh is scoped by the same entity stamps as the ledger sum — no + // actor list, so departed members' rows participate identically. + expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith({ + billingEntity: { type: 'organization', id: 'org-1' }, + periodStart: new Date('2026-07-01T00:00:00.000Z'), + periodEnd: new Date('2026-08-01T00:00:00.000Z'), + planDollars: 40, + seats: 2, + }) + expect(overage).toBe(80) }) }) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index b62d3ad5e9c..ff0308e593c 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, userStats } from '@sim/db/schema' +import { organization, subscription, userStats } from '@sim/db/schema' import { and, desc, eq, inArray } from 'drizzle-orm' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { @@ -10,7 +10,6 @@ import { ensureUserStatsExists } from '@/lib/billing/core/usage' import { COPILOT_USAGE_SOURCES, getBillingPeriodUsageCost, - getBillingPeriodUsageCostByUser, getBillingPeriodUsageCostWithSourceSubset, } from '@/lib/billing/core/usage-log' import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' @@ -124,7 +123,6 @@ export async function computeOrgOverageAmount(params: { periodEnd: Date | null organizationId: string pooledLedgerUsage: number - memberIds: string[] }): Promise<{ effectiveUsage: number baseSubscriptionAmount: number @@ -135,14 +133,13 @@ export async function computeOrgOverageAmount(params: { let dailyRefreshDeduction = 0 const planDollars = getPlanTierDollars(params.plan) - if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) { + if (planDollars > 0 && params.periodStart) { dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: params.memberIds, + billingEntity: { type: 'organization', id: params.organizationId }, periodStart: params.periodStart, periodEnd: params.periodEnd ?? null, planDollars, seats: params.seats || 1, - billingEntity: { type: 'organization', id: params.organizationId }, }) } @@ -180,23 +177,13 @@ export async function calculateSubscriptionOverage(sub: { const isOrgScoped = await isSubscriptionOrgScoped(sub) if (isOrgScoped) { - const memberRows = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) - const usageByUser = + const ledgerUsage = sub.periodStart && sub.periodEnd - ? await getBillingPeriodUsageCostByUser( + ? await getBillingPeriodUsageCost( { type: 'organization', id: sub.referenceId }, { start: sub.periodStart, end: sub.periodEnd } ) - : new Map() - let ledgerUsage = 0 - for (const cost of usageByUser.values()) ledgerUsage += cost - // Union current members with every actor holding org-attributed rows this - // period: a member who departed mid-period still bills here, so their - // daily-refresh consumption must offset the overage too. - const memberIds = [...new Set([...memberRows.map((row) => row.userId), ...usageByUser.keys()])] + : 0 const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({ plan: sub.plan, @@ -205,7 +192,6 @@ export async function calculateSubscriptionOverage(sub: { periodEnd: sub.periodEnd ?? null, organizationId: sub.referenceId, pooledLedgerUsage: ledgerUsage, - memberIds, }) totalOverageDecimal = toDecimal(totalOverage) @@ -238,11 +224,10 @@ export async function calculateSubscriptionOverage(sub: { const planDollars = getPlanTierDollars(sub.plan) if (planDollars > 0 && sub.periodStart) { dailyRefreshDeduction = await computeDailyRefreshConsumed({ - userIds: [sub.referenceId], + billingEntity: { type: 'user', id: sub.referenceId }, periodStart: sub.periodStart, periodEnd: sub.periodEnd ?? null, planDollars, - billingEntity: { type: 'user', id: sub.referenceId }, }) } } @@ -322,11 +307,10 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie if (planDollars > 0) { refreshDeduction = await computeDailyRefreshConsumed( { - userIds: [userId], + billingEntity: { type: 'user', id: userId }, periodStart: personalSubscription.periodStart, periodEnd: personalSubscription.periodEnd ?? null, planDollars, - billingEntity: { type: 'user', id: userId }, }, executor ) diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index c3f02cce9a1..b3ba3e4e112 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -10,7 +10,7 @@ import { getBillingPeriodUsageCostByUser, type UsageQueryPeriod, } from '@/lib/billing/core/usage-log' -import { computeOrganizationDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' +import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers' import { getEffectiveSeats, @@ -274,9 +274,9 @@ export async function getOrganizationBillingData( if (isPaid(subscription.plan) && subscription.periodStart) { const planDollars = getPlanTierDollars(subscription.plan) if (planDollars > 0) { - const refreshConsumed = await computeOrganizationDailyRefreshConsumed( + const refreshConsumed = await computeDailyRefreshConsumed( { - organizationId: subscription.referenceId, + billingEntity: { type: 'organization', id: subscription.referenceId }, periodStart: subscription.periodStart, periodEnd: subscription.periodEnd ?? null, planDollars, diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 7cad5c1977e..51e19699c18 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -22,7 +22,7 @@ import { type ResolvedUsagePeriod, resolveSubscriptionUsagePeriod, } from '@/lib/billing/core/reporting-period' -import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' +import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers' import { @@ -63,39 +63,29 @@ export interface UsageLimitSubscription { } /** - * Member ids plus the pooled previous-period bookkeeping total for an - * organization. Current-period usage is never read here — it is always the - * attributed usage_log ledger. `lastPeriodCost` rows are written by the - * cycle-close sweep from ledger sums. + * Pooled previous-period bookkeeping total for an organization — the sum of + * member `lastPeriodCost` rows, which the cycle-close sweep writes from + * ledger sums. Current-period usage is never read here — it is always the + * attributed usage_log ledger. * * Uses `LEFT JOIN` so members whose `userStats` row is missing still - * appear (contributing 0), which keeps `memberIds` complete for - * downstream refresh / bounds computations. + * count (contributing 0). */ -export async function getOrgMemberBillingRollup( +export async function getOrgLastPeriodCost( organizationId: string, executor: DbClient = db -): Promise<{ memberIds: string[]; lastPeriodCost: number }> { +): Promise { const rows = await executor - .select({ - userId: member.userId, - lastPeriodCost: userStats.lastPeriodCost, - }) + .select({ lastPeriodCost: userStats.lastPeriodCost }) .from(member) .leftJoin(userStats, eq(member.userId, userStats.userId)) .where(eq(member.organizationId, organizationId)) let lastPeriodCost = new Decimal(0) - const memberIds: string[] = [] for (const row of rows) { - memberIds.push(row.userId) lastPeriodCost = lastPeriodCost.plus(toDecimal(row.lastPeriodCost)) } - - return { - memberIds, - lastPeriodCost: toNumber(lastPeriodCost), - } + return toNumber(lastPeriodCost) } /** @@ -244,9 +234,6 @@ export async function getResolvedUserUsageData( let lastPeriodCost = toNumber(toDecimal(stats.lastPeriodCost)) 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) { const orgLimit = await getOrgUsageLimit( @@ -257,9 +244,7 @@ export async function getResolvedUserUsageData( ) limit = orgLimit.limit - const rollup = await getOrgMemberBillingRollup(subscription.referenceId, executor) - orgMemberIds = rollup.memberIds - lastPeriodCost = rollup.lastPeriodCost + lastPeriodCost = await getOrgLastPeriodCost(subscription.referenceId, executor) currentUsage = await getBillingPeriodUsageCost( { type: 'organization', id: subscription.referenceId }, billingPeriod, @@ -279,32 +264,18 @@ export async function getResolvedUserUsageData( if (subscription && isPaid(subscription.plan) && billingPeriodStart) { const planDollars = getPlanTierDollars(subscription.plan) if (planDollars > 0) { - if (orgScoped) { - if (orgMemberIds.length > 0) { - dailyRefreshConsumed = await computeDailyRefreshConsumed( - { - userIds: orgMemberIds, - periodStart: billingPeriodStart, - periodEnd: billingPeriodEnd, - planDollars, - seats: subscription.seats || 1, - billingEntity: { type: 'organization', id: subscription.referenceId }, - }, - executor - ) - } - } else { - dailyRefreshConsumed = await computeDailyRefreshConsumed( - { - userIds: [userId], - periodStart: billingPeriodStart, - periodEnd: billingPeriodEnd, - planDollars, - billingEntity: { type: 'user', id: userId }, - }, - executor - ) - } + dailyRefreshConsumed = await computeDailyRefreshConsumed( + { + billingEntity: orgScoped + ? { type: 'organization', id: subscription.referenceId } + : { type: 'user', id: userId }, + periodStart: billingPeriodStart, + periodEnd: billingPeriodEnd, + planDollars, + seats: orgScoped ? subscription.seats || 1 : undefined, + }, + executor + ) } } @@ -668,9 +639,6 @@ export async function getEffectiveCurrentPeriodCost( const subscription = await getHighestPrioritySubscription(userId, { executor }) const orgScoped = isOrgScopedSubscription(subscription, userId) - let rawCost: number - let refreshUserIds: string[] = [userId] - const billingPeriod = resolveSubscriptionUsagePeriod(subscription) ?? { ...defaultBillingPeriod(), source: 'default' as const, @@ -678,24 +646,11 @@ export async function getEffectiveCurrentPeriodCost( interval: null, } - if (orgScoped && subscription) { - const rollup = await getOrgMemberBillingRollup(subscription.referenceId, executor) - if (rollup.memberIds.length === 0) return 0 - refreshUserIds = rollup.memberIds - rawCost = await getBillingPeriodUsageCost( - { type: 'organization', id: subscription.referenceId }, - billingPeriod, - undefined, - executor - ) - } else { - rawCost = await getBillingPeriodUsageCost( - { type: 'user', id: userId }, - billingPeriod, - undefined, - executor - ) - } + const billingEntity: BillingEntity = + orgScoped && subscription + ? { type: 'organization', id: subscription.referenceId } + : { type: 'user', id: userId } + const rawCost = await getBillingPeriodUsageCost(billingEntity, billingPeriod, undefined, executor) if (!subscription || !isPaid(subscription.plan) || !subscription.periodStart) { return rawCost @@ -706,15 +661,11 @@ export async function getEffectiveCurrentPeriodCost( const refreshConsumed = await computeDailyRefreshConsumed( { - userIds: refreshUserIds, + billingEntity, periodStart: subscription.periodStart, periodEnd: subscription.periodEnd ?? null, planDollars, seats: subscription.seats || 1, - billingEntity: - orgScoped && subscription - ? { type: 'organization', id: subscription.referenceId } - : { type: 'user', id: userId }, }, executor ) diff --git a/apps/sim/lib/billing/credits/daily-refresh.test.ts b/apps/sim/lib/billing/credits/daily-refresh.test.ts index 22aca7064ee..c1f566609e9 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.test.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.test.ts @@ -23,7 +23,6 @@ vi.mock('@/lib/billing/constants', () => ({ import { computeBillingPeriodUsageWithDailyRefresh, computeDailyRefreshConsumed, - getDailyRefreshDollars, } from '@/lib/billing/credits/daily-refresh' describe('computeBillingPeriodUsageWithDailyRefresh', () => { @@ -44,7 +43,6 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => { computeBillingPeriodUsageWithDailyRefresh({ billingEntity: { type: 'organization', id: 'org-1' }, billingPeriod: { start: periodStart, end: periodEnd }, - userIds: ['user-1'], refreshPeriodStart: periodStart, refreshPeriodEnd: periodEnd, planDollars: 25, @@ -72,7 +70,6 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => { end: reportingEnd, source: 'reporting', }, - userIds: ['user-1'], refreshPeriodStart: periodStart, refreshPeriodEnd: periodEnd, planDollars: 25, @@ -98,7 +95,7 @@ describe('computeDailyRefreshConsumed', () => { it('returns 0 when planDollars is 0', async () => { const result = await computeDailyRefreshConsumed({ - userIds: ['user-1'], + billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-01'), planDollars: 0, }) @@ -106,24 +103,49 @@ describe('computeDailyRefreshConsumed', () => { expect(dbChainMockFns.groupBy).not.toHaveBeenCalled() }) - it('returns 0 when userIds is empty', async () => { + it('returns 0 when periodEnd is before periodStart', async () => { const result = await computeDailyRefreshConsumed({ - userIds: [], - periodStart: new Date('2026-03-01'), + billingEntity: { type: 'user', id: 'user-1' }, + periodStart: new Date('2026-03-10'), + periodEnd: new Date('2026-03-01'), planDollars: 25, }) expect(result).toBe(0) - expect(dbChainMockFns.groupBy).not.toHaveBeenCalled() }) - it('returns 0 when periodEnd is before periodStart', async () => { - const result = await computeDailyRefreshConsumed({ - userIds: ['user-1'], - periodStart: new Date('2026-03-10'), - periodEnd: new Date('2026-03-01'), + it('scopes rows by the entity and period stamps, never an actor list', async () => { + dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '0.10' }]) + const periodStart = new Date('2026-03-01') + + await computeDailyRefreshConsumed({ + billingEntity: { type: 'organization', id: 'org-1' }, + periodStart, + periodEnd: new Date('2026-03-02'), planDollars: 25, }) - expect(result).toBe(0) + + expect(drizzleOrmMock.eq).toHaveBeenCalledWith( + schemaMock.usageLog.billingEntityType, + 'organization' + ) + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.usageLog.billingEntityId, 'org-1') + expect(drizzleOrmMock.eq).toHaveBeenCalledWith( + schemaMock.usageLog.billingPeriodStart, + periodStart + ) + expect(drizzleOrmMock.inArray).not.toHaveBeenCalled() + }) + + it('rejects windows beyond the supported annual bound', async () => { + await expect( + computeDailyRefreshConsumed({ + billingEntity: { type: 'organization', id: 'org-1' }, + periodStart: new Date('2024-01-01'), + periodEnd: new Date('2026-03-01'), + planDollars: 25, + }) + ).rejects.toThrow('annual bound') + expect(dbChainMockFns.groupBy).not.toHaveBeenCalled() }) it('caps each day at the daily refresh allowance', async () => { @@ -134,7 +156,7 @@ describe('computeDailyRefreshConsumed', () => { ]) const result = await computeDailyRefreshConsumed({ - userIds: ['user-1'], + billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-01'), periodEnd: new Date('2026-03-04'), planDollars: 25, @@ -152,7 +174,7 @@ describe('computeDailyRefreshConsumed', () => { dbChainMockFns.groupBy.mockResolvedValueOnce([]) const result = await computeDailyRefreshConsumed({ - userIds: ['user-1'], + billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-01'), periodEnd: new Date('2026-03-04'), planDollars: 25, @@ -165,7 +187,7 @@ describe('computeDailyRefreshConsumed', () => { dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '2.00' }]) const result = await computeDailyRefreshConsumed({ - userIds: ['user-1', 'user-2', 'user-3'], + billingEntity: { type: 'organization', id: 'org-1' }, periodStart: new Date('2026-03-01'), periodEnd: new Date('2026-03-02'), planDollars: 100, @@ -181,7 +203,7 @@ describe('computeDailyRefreshConsumed', () => { dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '50.00' }]) const result = await computeDailyRefreshConsumed({ - userIds: ['user-1', 'user-2'], + billingEntity: { type: 'organization', id: 'org-1' }, periodStart: new Date('2026-03-01'), periodEnd: new Date('2026-03-02'), planDollars: 100, @@ -197,7 +219,7 @@ describe('computeDailyRefreshConsumed', () => { dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: null }]) const result = await computeDailyRefreshConsumed({ - userIds: ['user-1'], + billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-01'), periodEnd: new Date('2026-03-02'), planDollars: 25, @@ -206,17 +228,3 @@ describe('computeDailyRefreshConsumed', () => { expect(result).toBe(0) }) }) - -describe('getDailyRefreshDollars', () => { - it('computes correct daily refresh for Pro ($25)', () => { - expect(getDailyRefreshDollars(25)).toBe(0.25) - }) - - it('computes correct daily refresh for Max ($100)', () => { - expect(getDailyRefreshDollars(100)).toBe(1.0) - }) - - it('returns 0 for $0 plan', () => { - expect(getDailyRefreshDollars(0)).toBe(0) - }) -}) diff --git a/apps/sim/lib/billing/credits/daily-refresh.ts b/apps/sim/lib/billing/credits/daily-refresh.ts index c11d65c6b8a..3c903c53cd6 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.ts @@ -9,12 +9,19 @@ * SUM( MIN(day_usage, daily_refresh_amount) ) for each day * * This is subtracted from ledger period usage to derive "effective billable usage". + * + * Refresh reads are scoped by the ledger's write-time entity and period + * stamps — never by an actor list. Every row attributed to the billing entity + * participates in that entity's refresh, exactly like it participates in the + * entity's ledger total: rows from a member who departed the organization + * mid-period stay stamped to the organization, and a member's pre-join rows + * are user-stamped, so they can never appear under an organization entity. */ import { db } from '@sim/db' import { usageLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, gte, inArray, lt, or, sql, sum } from 'drizzle-orm' +import { and, eq, gte, lt, or, sql, sum } from 'drizzle-orm' import { DAILY_REFRESH_RATE } from '@/lib/billing/constants' import type { BillingEntity, UsageQueryPeriod } from '@/lib/billing/core/usage-log' import type { DbClient } from '@/lib/db/types' @@ -27,7 +34,6 @@ const MAX_BILLING_PERIOD_DAYS = 370 interface BillingPeriodUsageWithDailyRefreshParams { billingEntity: BillingEntity billingPeriod: UsageQueryPeriod - userIds: string[] refreshPeriodStart: Date refreshPeriodEnd?: Date | null planDollars: number @@ -37,9 +43,9 @@ interface BillingPeriodUsageWithDailyRefreshParams { /** * Reads the exact ledger total and the daily-refresh buckets from one snapshot. * - * The two aggregates intentionally keep different predicates. Ledger totals use - * both captured period bounds (or a reporting-time window), while refresh uses - * the captured period start, eligible users, and per-user time bounds. + * The two aggregates intentionally keep different predicates. Ledger totals + * use both captured period bounds (or a reporting-time window), while refresh + * uses the captured period start plus a created-at day window. */ export async function computeBillingPeriodUsageWithDailyRefresh( params: BillingPeriodUsageWithDailyRefreshParams, @@ -48,7 +54,6 @@ export async function computeBillingPeriodUsageWithDailyRefresh( const { billingEntity, billingPeriod, - userIds, refreshPeriodStart, refreshPeriodEnd, planDollars, @@ -57,23 +62,14 @@ export async function computeBillingPeriodUsageWithDailyRefresh( const now = new Date() const cap = refreshPeriodEnd && refreshPeriodEnd < now ? refreshPeriodEnd : now const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats - const refreshUserFilters = - cap > refreshPeriodStart - ? [ - and( - inArray(usageLog.userId, userIds), - gte(usageLog.createdAt, refreshPeriodStart), - lt(usageLog.createdAt, cap) - ), - ] - : [] - const refreshFilter = - refreshUserFilters.length > 0 - ? and( - eq(usageLog.billingPeriodStart, refreshPeriodStart), - refreshUserFilters.length === 1 ? refreshUserFilters[0] : or(...refreshUserFilters) - ) - : sql`false` + const refreshWindowActive = cap > refreshPeriodStart + const refreshFilter = refreshWindowActive + ? and( + eq(usageLog.billingPeriodStart, refreshPeriodStart), + gte(usageLog.createdAt, refreshPeriodStart), + lt(usageLog.createdAt, cap) + ) + : sql`false` const ledgerPeriodFilter = billingPeriod.source === 'reporting' ? and(gte(usageLog.createdAt, billingPeriod.start), lt(usageLog.createdAt, billingPeriod.end)) @@ -89,14 +85,13 @@ export async function computeBillingPeriodUsageWithDailyRefresh( billingPeriod.source === 'reporting' && refreshPeriodStart >= billingPeriod.start && cap <= billingPeriod.end - const scanFilter = - refreshUserFilters.length === 0 - ? ledgerPeriodFilter - : sameCapturedPeriodStart - ? eq(usageLog.billingPeriodStart, billingPeriod.start) - : reportingWindowContainsRefresh - ? ledgerPeriodFilter - : or(ledgerPeriodFilter, refreshFilter) + const scanFilter = !refreshWindowActive + ? ledgerPeriodFilter + : sameCapturedPeriodStart + ? eq(usageLog.billingPeriodStart, billingPeriod.start) + : reportingWindowContainsRefresh + ? ledgerPeriodFilter + : or(ledgerPeriodFilter, refreshFilter) const rows = await executor .select({ @@ -135,28 +130,30 @@ export async function computeBillingPeriodUsageWithDailyRefresh( } /** - * Compute the total daily refresh credits consumed in the current billing period - * using a single aggregating SQL query grouped by day offset. + * Compute the total daily refresh credits a billing entity consumed in a + * period, using a single aggregating SQL query grouped by day offset. * * For each day from `periodStart`: * consumed_today = MIN(actual_usage_today, daily_refresh_dollars) * + * Rows are scoped purely by the entity and period stamps — see the module + * header for why no actor list participates. + * * @returns Total dollars of refresh consumed across all days (to subtract from usage) */ export async function computeDailyRefreshConsumed( params: { - userIds: string[] + billingEntity: BillingEntity periodStart: Date periodEnd?: Date | null planDollars: number seats?: number - billingEntity?: { type: 'user' | 'organization'; id: string } }, executor: DbClient = db ): Promise { - const { userIds, periodStart, periodEnd, planDollars, seats = 1, billingEntity } = params + const { billingEntity, periodStart, periodEnd, planDollars, seats = 1 } = params - if (planDollars <= 0 || userIds.length === 0) return 0 + if (planDollars <= 0) return 0 const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats @@ -166,24 +163,9 @@ export async function computeDailyRefreshConsumed( if (cap <= periodStart) return 0 const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY) - if (dayCount <= 0) return 0 - - const billingEntityFilter = billingEntity - ? and( - eq(usageLog.billingEntityType, billingEntity.type), - eq(usageLog.billingEntityId, billingEntity.id), - eq(usageLog.billingPeriodStart, periodStart) - ) - : undefined - - const rowFilters = [ - and( - inArray(usageLog.userId, userIds), - billingEntityFilter, - gte(usageLog.createdAt, periodStart), - lt(usageLog.createdAt, cap) - ), - ] + if (dayCount > MAX_BILLING_PERIOD_DAYS) { + throw new Error('Billing period exceeds the supported annual bound') + } const rows = await executor .select({ @@ -194,7 +176,15 @@ export async function computeDailyRefreshConsumed( dayTotal: sum(usageLog.cost).as('day_total'), }) .from(usageLog) - .where(rowFilters.length === 1 ? rowFilters[0] : or(...rowFilters)) + .where( + and( + eq(usageLog.billingEntityType, billingEntity.type), + eq(usageLog.billingEntityId, billingEntity.id), + eq(usageLog.billingPeriodStart, periodStart), + gte(usageLog.createdAt, periodStart), + lt(usageLog.createdAt, cap) + ) + ) .groupBy(sql`day_index`) let totalConsumed = 0 @@ -204,7 +194,7 @@ export async function computeDailyRefreshConsumed( } logger.debug('Daily refresh computed', { - userCount: userIds.length, + billingEntityType: billingEntity.type, periodStart: periodStart.toISOString(), days: dayCount, dailyRefreshDollars, @@ -213,61 +203,3 @@ export async function computeDailyRefreshConsumed( return totalConsumed } - -export async function computeOrganizationDailyRefreshConsumed( - params: { - organizationId: string - periodStart: Date - periodEnd?: Date | null - planDollars: number - seats?: number - }, - executor: DbClient = db -): Promise { - const { organizationId, periodStart, periodEnd, planDollars, seats = 1 } = params - if (planDollars <= 0) return 0 - - const now = new Date() - const cap = periodEnd && periodEnd < now ? periodEnd : now - if (cap <= periodStart) return 0 - const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY) - if (dayCount > MAX_BILLING_PERIOD_DAYS) { - throw new Error('Organization billing period exceeds the supported annual bound') - } - - const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats - // Entity/period stamps fully scope the rows: every org-attributed row — - // including a departed member's — participates in the org's refresh, and a - // member's pre-join rows are user-stamped so they can never appear here. - const rows = await executor - .select({ - dayIndex: - sql`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as( - 'day_index' - ), - dayTotal: sum(usageLog.cost).as('day_total'), - }) - .from(usageLog) - .where( - and( - eq(usageLog.billingEntityType, 'organization'), - eq(usageLog.billingEntityId, organizationId), - eq(usageLog.billingPeriodStart, periodStart), - gte(usageLog.createdAt, periodStart), - lt(usageLog.createdAt, cap) - ) - ) - .groupBy(sql`day_index`) - - return rows.reduce((total, row) => { - const dayUsage = Number.parseFloat(row.dayTotal ?? '0') - return total + Math.min(dayUsage, dailyRefreshDollars) - }, 0) -} - -/** - * Get the daily refresh allowance in dollars for a plan. - */ -export function getDailyRefreshDollars(planDollars: number): number { - return planDollars * DAILY_REFRESH_RATE -} diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index 0a83ffad1da..3c783ec842e 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -84,6 +84,7 @@ vi.mock('@/lib/posthog/server', () => ({ import { claimTerminalPeriod, closeElapsedBillingPeriod, + closeElapsedPeriodBeforeDeletion, isSubscriptionCycleCloseCurrent, sweepBillingCycleCloses, writeFinalPeriodBookkeeping, @@ -194,7 +195,6 @@ describe('closeElapsedBillingPeriod', () => { periodEnd: PERIOD_START, organizationId: 'org-1', pooledLedgerUsage: 150, - memberIds: ['owner-1'], }) expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1) @@ -280,9 +280,11 @@ describe('closeElapsedBillingPeriod', () => { expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() }) - it('includes departed members with billed ledger usage in the refresh actor set', async () => { + it('bills departed members through the pooled entity sums, not a roster', async () => { // 'departed-1' has org-attributed rows in the closed period but no member - // row anymore; their refresh consumption must still offset the overage. + // row anymore; the pooled sum carries them, and the entity-scoped refresh + // inside computeOrgOverageAmount offsets them identically — no actor list + // is passed anywhere. mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue( new Map([ ['owner-1', 100], @@ -293,12 +295,14 @@ describe('closeElapsedBillingPeriod', () => { await closeElapsedBillingPeriod(subRow()) - expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith( - expect.objectContaining({ - pooledLedgerUsage: 150, - memberIds: ['owner-1', 'departed-1'], - }) - ) + expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith({ + plan: 'team', + seats: null, + periodStart: PREV_PERIOD_START, + periodEnd: PERIOD_START, + organizationId: 'org-1', + pooledLedgerUsage: 150, + }) }) it('applies organization credits before invoicing and skips Stripe when covered', async () => { @@ -447,6 +451,54 @@ describe('writeFinalPeriodBookkeeping', () => { }) }) +describe('closeElapsedPeriodBeforeDeletion', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsSubscriptionOrgScoped.mockResolvedValue(true) + mockIsEnterprise.mockReturnValue(false) + mockIsFree.mockReturnValue(false) + mockResolveSubscriptionUsagePeriod.mockReturnValue(null) + mockGetPlanTierDollars.mockReturnValue(40) + mockGetPlanPricing.mockReturnValue({ basePrice: 40 }) + mockComputeDailyRefreshConsumed.mockResolvedValue(0) + mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 150]])) + mockComputeOrgOverageAmount.mockResolvedValue({ + effectiveUsage: 150, + baseSubscriptionAmount: 80, + dailyRefreshDeduction: 0, + totalOverage: 70, + }) + dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) + }) + + it('closes a lagging period with the settlement grace bypassed', async () => { + // Rollover 30 minutes ago — inside the grace the sweep would honor. The + // deletion path cannot wait: no later sweep revisits a canceled sub, so + // it settles the elapsed period now with whatever rows have landed. + const recentRollover = new Date(Date.now() - 30 * 60 * 1000) + queueTableRows(schemaMock.subscription, [subRow({ periodStart: recentRollover })]) + queueOrgCloseReads() + + await closeElapsedPeriodBeforeDeletion('sub-1') + + expect(mockEnqueueOutboxEvent).toHaveBeenCalledTimes(1) + const markerSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date + ) + expect(markerSet).toBeDefined() + }) + + it('no-ops when the close marker is already current', async () => { + queueTableRows(schemaMock.subscription, [subRow({ lastClosedPeriodStart: PERIOD_START })]) + + await closeElapsedPeriodBeforeDeletion('sub-1') + + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled() + }) +}) + describe('claimTerminalPeriod', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 3926e046bc8..430fcb8cbdf 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -144,6 +144,48 @@ export async function isSubscriptionCycleCloseCurrent( ) } +/** + * Close any elapsed-but-unclosed billing period for a subscription that is + * being deleted, ahead of its terminal settlement. A deleted subscription + * leaves the sweep's candidate set (its status leaves + * `ENTITLED_SUBSCRIPTION_STATUSES`), so this is the last chance to settle a + * period the sweep has not caught up to — without it, `claimTerminalPeriod` + * would advance the marker past the elapsed period and silently forgive its + * final overage, while the elapsed period's threshold collections would + * wrongly offset the terminal window's. The settlement grace is bypassed: + * no later sweep will revisit, so straggler rows are forgiven exactly like + * the terminal settlement's own. + */ +export async function closeElapsedPeriodBeforeDeletion(subscriptionId: string): Promise { + const [row] = await db + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.id, subscriptionId)) + .limit(1) + if (!row?.periodStart) return + const lagging = + row.lastClosedPeriodStart === null || + row.lastClosedPeriodStart.getTime() < row.periodStart.getTime() + if (!lagging) return + + const result = await closeElapsedBillingPeriod(row, { bypassSettlementGrace: true }) + if (result.status === 'skipped') { + // The close deferred (missing Stripe linkage, ownerless org, or a roster + // change mid-close). Deletion proceeds — blocking member downgrades on an + // unbillable period is the wrong trade — so the residual overage is + // forgiven; the close path already logged the specific cause. + logger.error( + 'Deletion proceeding past an unclosable elapsed period; residual overage forgiven', + { + subscriptionId, + plan: row.plan, + marker: row.lastClosedPeriodStart?.toISOString() ?? null, + periodStart: row.periodStart.toISOString(), + } + ) + } +} + /** * Claim the terminal period for a subscription that is being deleted, BEFORE * the deletion handler computes and charges final overage. Reads the @@ -152,8 +194,9 @@ export async function isSubscriptionCycleCloseCurrent( * transaction, serializing with the sweep on the subscription row: an * in-flight sweep close then fails its guarded marker claim and rolls back — * including its outbox invoice — so deletion and sweep can never both bill - * the same period. Returns the fresh period bounds for the deletion flow to - * settle against. + * the same period. Call `closeElapsedPeriodBeforeDeletion` first so a lagging + * elapsed period is settled rather than jumped. Returns the fresh period + * bounds for the deletion flow to settle against. */ export async function claimTerminalPeriod( subscriptionId: string @@ -227,7 +270,10 @@ async function claimCloseMarker( * anchor. A null marker initializes to the current `periodStart` without * billing, so historical periods are never retroactively closed. */ -export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise { +export async function closeElapsedBillingPeriod( + sub: SubscriptionRow, + options: { bypassSettlementGrace?: boolean } = {} +): Promise { const base: CycleCloseResult = { status: 'skipped', subscriptionId: sub.id } if (!sub.periodStart || isFree(sub.plan)) return base @@ -250,7 +296,10 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise`max(${usageLog.billingPeriodStart})` }) + .select({ + // mapWith(column) applies the timestamp decoder — a raw aggregate + // bypasses column mapping, so the driver would return a string here. + start: sql`max(${usageLog.billingPeriodStart})`.mapWith( + usageLog.billingPeriodStart + ), + }) .from(usageLog) .where( and( @@ -324,16 +379,11 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise row.role === 'owner')?.userId ?? null) : sub.referenceId - // Every actor whose org-attributed usage is billed at this close, including - // members who departed mid-period: their ledger rows stay stamped to this - // organization's period, so their daily-refresh consumption must offset the - // overage exactly like a current member's. Current members with no rows stay - // in the set for their refresh bounds. - const overageActorIds = orgScoped - ? [...new Set([...memberIds, ...usageByUser.keys()])] - : memberIds // Final overage for the closed period (enterprise never bills overage). + // Refresh reads are scoped by the same entity/period stamps as the ledger + // sums, so a departed member's org-attributed rows offset the overage + // exactly like a current member's. let totalOverage = 0 if (!enterprise) { if (orgScoped) { @@ -344,7 +394,6 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise 0) { refreshConsumed = await computeDailyRefreshConsumed({ - userIds: [sub.referenceId], + billingEntity, periodStart: closeFrom, periodEnd: periodStart, planDollars, - billingEntity, }) } const { basePrice } = getPlanPricing(sub.plan) diff --git a/apps/sim/lib/billing/threshold-billing.test.ts b/apps/sim/lib/billing/threshold-billing.test.ts index 15c426dcd7e..cfab2c6016f 100644 --- a/apps/sim/lib/billing/threshold-billing.test.ts +++ b/apps/sim/lib/billing/threshold-billing.test.ts @@ -10,7 +10,7 @@ const { mockEnqueueOutboxEvent, mockGetEffectiveBillingStatus, mockGetHighestPrioritySubscription, - mockGetBillingPeriodUsageCostByUser, + mockGetBillingPeriodUsageCost, mockGetOrganizationSubscriptionUsable, mockHasUsableSubscriptionAccess, mockIsEnterprise, @@ -26,7 +26,7 @@ const { mockEnqueueOutboxEvent: vi.fn(), mockGetEffectiveBillingStatus: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), - mockGetBillingPeriodUsageCostByUser: vi.fn(), + mockGetBillingPeriodUsageCost: vi.fn(), mockGetOrganizationSubscriptionUsable: vi.fn(), mockHasUsableSubscriptionAccess: vi.fn(), mockIsEnterprise: vi.fn(), @@ -60,7 +60,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ })) vi.mock('@/lib/billing/core/usage-log', () => ({ - getBillingPeriodUsageCostByUser: mockGetBillingPeriodUsageCostByUser, + getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost, })) vi.mock('@/lib/billing/cycle-close', () => ({ @@ -184,7 +184,7 @@ describe('checkAndBillOverageThreshold', () => { mockIsFree.mockReturnValue(false) mockIsEnterprise.mockReturnValue(false) mockIsOrgScopedSubscription.mockReturnValue(false) - mockGetBillingPeriodUsageCostByUser.mockResolvedValue(new Map()) + mockGetBillingPeriodUsageCost.mockResolvedValue(0) mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(true) }) @@ -609,12 +609,8 @@ describe('checkAndBillOverageThreshold', () => { mockIsOrgScopedSubscription.mockReturnValue(true) mockIsOrganizationBillingBlocked.mockResolvedValue(false) mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription) - mockGetBillingPeriodUsageCostByUser.mockResolvedValue( - new Map([ - ['owner-1', 300], - ['departed-1', 50], - ]) - ) + // Pooled entity read — departed members' org-stamped rows are already in. + mockGetBillingPeriodUsageCost.mockResolvedValue(350) queueOrgReads() mockComputeOrgOverageAmount.mockResolvedValue({ totalOverage: 250, @@ -631,7 +627,6 @@ describe('checkAndBillOverageThreshold', () => { periodEnd: new Date('2026-06-01T00:00:00.000Z'), organizationId: userSubscription.referenceId, pooledLedgerUsage: 350, - memberIds: ['owner-1', 'departed-1'], }) expect(dbChainMockFns.transaction).toHaveBeenCalled() expect(mockComputeOrgOverageAmount.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index b7f65ec1074..47b660880e0 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -13,7 +13,7 @@ import { getHighestPrioritySubscription, getOrganizationSubscriptionUsable, } from '@/lib/billing/core/subscription' -import { type BillingEntity, getBillingPeriodUsageCostByUser } from '@/lib/billing/core/usage-log' +import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' import { isSubscriptionCycleCloseCurrent } from '@/lib/billing/cycle-close' import { isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { @@ -32,7 +32,6 @@ const OVERAGE_THRESHOLD = envNumber(env.OVERAGE_THRESHOLD_DOLLARS, DEFAULT_OVERA const USAGE_TOTAL_EPSILON = 0.000001 interface OrganizationUsageSnapshot { - memberIds: string[] ownerId: string memberSignature: string } @@ -615,20 +614,13 @@ async function checkAndBillOrganizationOverageThreshold( ownerId: usageSnapshot.ownerId, }) - const orgUsageByUser = + const ledgerUsage = orgSubscription.periodStart && orgSubscription.periodEnd - ? await getBillingPeriodUsageCostByUser( + ? await getBillingPeriodUsageCost( { type: 'organization', id: organizationId }, { start: orgSubscription.periodStart, end: orgSubscription.periodEnd } ) - : new Map() - let ledgerUsage = 0 - for (const cost of orgUsageByUser.values()) ledgerUsage += cost - // Union current members with every actor holding org-attributed rows this - // period: a member who departed mid-period still bills here, so their - // daily-refresh consumption must offset the overage too — same actor set - // as `calculateSubscriptionOverage` and the cycle close. - const overageActorIds = [...new Set([...usageSnapshot.memberIds, ...orgUsageByUser.keys()])] + : 0 const { totalOverage: currentOverage, @@ -641,7 +633,6 @@ async function checkAndBillOrganizationOverageThreshold( periodEnd: orgSubscription.periodEnd ?? null, organizationId, pooledLedgerUsage: ledgerUsage, - memberIds: overageActorIds, }) if (currentOverage < threshold) { @@ -950,7 +941,6 @@ function buildOrganizationUsageSnapshot( const sortedRows = [...rows].sort((a, b) => a.userId.localeCompare(b.userId)) return { - memberIds: sortedRows.map((row) => row.userId), ownerId: owner.userId, memberSignature: sortedRows.map((row) => `${row.userId}:${row.role}`).join('|'), } diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 220908a1923..ebfde387035 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -5,7 +5,11 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, ne } from 'drizzle-orm' import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' -import { claimTerminalPeriod, writeFinalPeriodBookkeeping } from '@/lib/billing/cycle-close' +import { + claimTerminalPeriod, + closeElapsedPeriodBeforeDeletion, + writeFinalPeriodBookkeeping, +} from '@/lib/billing/cycle-close' import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' import { requireStripeClient } from '@/lib/billing/stripe-client' @@ -280,11 +284,18 @@ export async function handleSubscriptionDeleted( 'subscription-deleted', idempotencyIdentifier, async () => { - // Claim the terminal period BEFORE computing or charging: this reads - // the row's fresh period (webhook payloads can be stale across a - // rollover) and serializes with the cycle-close sweep — an in-flight - // close fails its guarded marker claim and rolls back, so both paths - // can never bill the same period. + // Settle any elapsed period the sweep has not closed yet — a deleted + // subscription leaves the sweep's candidate set, so this is the last + // chance to bill it (and to reset the threshold tracker so the + // terminal settlement below is not offset by the elapsed period's + // collections). + await closeElapsedPeriodBeforeDeletion(subscription.id) + + // Then claim the terminal period BEFORE computing or charging: this + // reads the row's fresh period (webhook payloads can be stale across + // a rollover) and serializes with the cycle-close sweep — an + // in-flight close fails its guarded marker claim and rolls back, so + // both paths can never bill the same period. const terminal = await claimTerminalPeriod(subscription.id) const settlementPeriod = { periodStart: terminal.periodStart ?? subscription.periodStart ?? null, From a538a2fd31bc2aed8cf3c0dc3e031789b2883aef Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 15:57:38 -0700 Subject: [PATCH 12/14] fix(billing): bucket refresh by clamped day so stamped stragglers stay in the deduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh membership is now the entity/period stamps alone — identical to the ledger sums it offsets. A row written after the rollover but stamped to the elapsed period (attribution frozen at run start) is billed by the stamp-based close, so it must consume refresh too; created-at now only assigns the day bucket, clamped into the period, instead of excluding the row entirely. Co-Authored-By: Claude Fable 5 --- .../lib/billing/credits/daily-refresh.test.ts | 22 ++++++++++++ apps/sim/lib/billing/credits/daily-refresh.ts | 35 +++++++++---------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/billing/credits/daily-refresh.test.ts b/apps/sim/lib/billing/credits/daily-refresh.test.ts index c1f566609e9..7ea8987a934 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.test.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.test.ts @@ -136,6 +136,28 @@ describe('computeDailyRefreshConsumed', () => { expect(drizzleOrmMock.inArray).not.toHaveBeenCalled() }) + it('keeps straggler rows stamped to the period but written after its end', async () => { + // A run that started before the rollover inserts rows stamped with the + // elapsed period after it ended; the stamp-based close bills them, so the + // deduction must include them too (clamped into the final day bucket). + dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 30, dayTotal: '0.30' }]) + const periodStart = new Date('2026-03-01') + const periodEnd = new Date('2026-04-01') + + const result = await computeDailyRefreshConsumed({ + billingEntity: { type: 'user', id: 'user-1' }, + periodStart, + periodEnd, + planDollars: 25, + }) + + expect(result).toBe(0.25) + // Membership is stamp-only: no created-at bound may exclude a row the + // stamped ledger total includes. + expect(drizzleOrmMock.lt).not.toHaveBeenCalledWith(schemaMock.usageLog.createdAt, periodEnd) + expect(drizzleOrmMock.gte).not.toHaveBeenCalled() + }) + it('rejects windows beyond the supported annual bound', async () => { await expect( computeDailyRefreshConsumed({ diff --git a/apps/sim/lib/billing/credits/daily-refresh.ts b/apps/sim/lib/billing/credits/daily-refresh.ts index 3c903c53cd6..de16c15f63a 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.ts @@ -45,7 +45,9 @@ interface BillingPeriodUsageWithDailyRefreshParams { * * The two aggregates intentionally keep different predicates. Ledger totals * use both captured period bounds (or a reporting-time window), while refresh - * uses the captured period start plus a created-at day window. + * membership is the captured period-start stamp alone — created-at only + * buckets rows into days, clamped into the period (see + * `computeDailyRefreshConsumed` for why). */ export async function computeBillingPeriodUsageWithDailyRefresh( params: BillingPeriodUsageWithDailyRefreshParams, @@ -64,11 +66,7 @@ export async function computeBillingPeriodUsageWithDailyRefresh( const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats const refreshWindowActive = cap > refreshPeriodStart const refreshFilter = refreshWindowActive - ? and( - eq(usageLog.billingPeriodStart, refreshPeriodStart), - gte(usageLog.createdAt, refreshPeriodStart), - lt(usageLog.createdAt, cap) - ) + ? eq(usageLog.billingPeriodStart, refreshPeriodStart) : sql`false` const ledgerPeriodFilter = billingPeriod.source === 'reporting' @@ -81,22 +79,18 @@ export async function computeBillingPeriodUsageWithDailyRefresh( const sameCapturedPeriodStart = billingPeriod.source !== 'reporting' && billingPeriod.start.getTime() === refreshPeriodStart.getTime() - const reportingWindowContainsRefresh = - billingPeriod.source === 'reporting' && - refreshPeriodStart >= billingPeriod.start && - cap <= billingPeriod.end const scanFilter = !refreshWindowActive ? ledgerPeriodFilter : sameCapturedPeriodStart ? eq(usageLog.billingPeriodStart, billingPeriod.start) - : reportingWindowContainsRefresh - ? ledgerPeriodFilter - : or(ledgerPeriodFilter, refreshFilter) + : or(ledgerPeriodFilter, refreshFilter) + const startEpoch = Math.floor(refreshPeriodStart.getTime() / 1000) + const capEpoch = Math.floor(cap.getTime() / 1000) const rows = await executor .select({ dayIndex: - sql`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(refreshPeriodStart.getTime() / 1000)}) / 86400)`.as( + sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 86400)`.as( 'day_index' ), ledgerTotal: @@ -167,10 +161,17 @@ export async function computeDailyRefreshConsumed( throw new Error('Billing period exceeds the supported annual bound') } + // Membership mirrors the ledger sums exactly: the entity and period stamps + // alone. Created-at only assigns the day bucket, clamped into the period — + // a straggler row written after the rollover (billing attribution is frozen + // at run start) is billed by the stamp-based close, so it must consume + // refresh on the period's final day rather than fall out of the deduction. + const startEpoch = Math.floor(periodStart.getTime() / 1000) + const capEpoch = Math.floor(cap.getTime() / 1000) const rows = await executor .select({ dayIndex: - sql`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as( + sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 86400)`.as( 'day_index' ), dayTotal: sum(usageLog.cost).as('day_total'), @@ -180,9 +181,7 @@ export async function computeDailyRefreshConsumed( and( eq(usageLog.billingEntityType, billingEntity.type), eq(usageLog.billingEntityId, billingEntity.id), - eq(usageLog.billingPeriodStart, periodStart), - gte(usageLog.createdAt, periodStart), - lt(usageLog.createdAt, cap) + eq(usageLog.billingPeriodStart, periodStart) ) ) .groupBy(sql`day_index`) From 482413e66ebdb8e35e936610a09a06c678310891 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 16:11:34 -0700 Subject: [PATCH 13/14] fix(billing): pair the overage tracker with the marker's period and pin test clocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit billedOverageThisPeriod only ever holds collections for the period that began at the close marker — the threshold gate blocks settlement whenever the marker lags. Both consumers now honor that pairing: a close that skipped forgiven periods counts nothing from the tracker against the period it bills, and the deletion settlement ignores the tracker when the marker was still lagging at claim time. Ignoring is provably safe in both cases because a lagging marker means no current-period collections exist. The cycle-close and daily-refresh suites pin the system clock: their grace and window checks compare fixed period fixtures against Date.now(), which made them dependent on the host date. Co-Authored-By: Claude Fable 5 --- .../lib/billing/credits/daily-refresh.test.ts | 17 ++++- apps/sim/lib/billing/cycle-close.test.ts | 67 ++++++++++++++++++- apps/sim/lib/billing/cycle-close.ts | 39 +++++++++-- apps/sim/lib/billing/webhooks/subscription.ts | 10 ++- 4 files changed, 122 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/billing/credits/daily-refresh.test.ts b/apps/sim/lib/billing/credits/daily-refresh.test.ts index 7ea8987a934..86dbb2c0dd8 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.test.ts +++ b/apps/sim/lib/billing/credits/daily-refresh.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { dbChainMockFns, drizzleOrmMock, schemaMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('drizzle-orm', () => { const sqlTag = () => { @@ -25,6 +25,21 @@ import { computeDailyRefreshConsumed, } from '@/lib/billing/credits/daily-refresh' +/** + * Refresh caps windows at `Date.now()`, so the suite pins the clock after + * every fixture period to stay hermetic on any host date. + */ +const FROZEN_NOW = new Date('2026-08-15T00:00:00.000Z') + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FROZEN_NOW) +}) + +afterAll(() => { + vi.useRealTimers() +}) + describe('computeBillingPeriodUsageWithDailyRefresh', () => { const periodStart = new Date('2026-03-01T00:00:00.000Z') const periodEnd = new Date('2026-04-01T00:00:00.000Z') diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index 3c783ec842e..ec4b6f16297 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -95,6 +95,21 @@ type SubInput = Parameters[0] const PERIOD_START = new Date('2026-08-01T00:00:00.000Z') const PREV_PERIOD_START = new Date('2026-07-01T00:00:00.000Z') +/** + * The grace gate and lagging checks compare fixed period boundaries against + * `Date.now()`, so the suite pins the clock to stay hermetic on any host date. + */ +const FROZEN_NOW = new Date('2026-08-15T00:00:00.000Z') + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FROZEN_NOW) +}) + +afterAll(() => { + vi.useRealTimers() +}) + function subRow(overrides: Partial> = {}): SubInput { return { id: 'sub-1', @@ -305,6 +320,35 @@ describe('closeElapsedBillingPeriod', () => { }) }) + it('subtracts the current period tracker from the final overage', async () => { + queueOrgCloseReads({ trackerRow: { billedOverageThisPeriod: '30', creditBalance: '0' } }) + + const result = await closeElapsedBillingPeriod(subRow()) + + expect(result.status).toBe('closed') + expect(result.overageBilled).toBe(40) + const [, , payload] = mockEnqueueOutboxEvent.mock.calls[0] + expect(payload).toMatchObject({ amountCents: 4000 }) + }) + + it('ignores the stale tracker when the close skipped forgiven periods', async () => { + // Marker two intervals back: the close forgives the older period and + // bills [Jul 1, Aug 1) only. The tracker's collections belong to the + // period that began at the marker, so none of them offset this close. + const staleMarker = new Date('2026-05-01T00:00:00.000Z') + queueOrgCloseReads({ + markerRow: { lastClosedPeriodStart: staleMarker }, + trackerRow: { billedOverageThisPeriod: '30', creditBalance: '0' }, + }) + + const result = await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: staleMarker })) + + expect(result.status).toBe('closed') + expect(result.overageBilled).toBe(70) + const [, , payload] = mockEnqueueOutboxEvent.mock.calls[0] + expect(payload).toMatchObject({ amountCents: 7000 }) + }) + it('applies organization credits before invoicing and skips Stripe when covered', async () => { queueOrgCloseReads({ orgRow: { creditBalance: '100' } }) @@ -508,24 +552,43 @@ describe('claimTerminalPeriod', () => { it('claims the marker from the fresh row period and returns it for settlement', async () => { queueTableRows(schemaMock.subscription, [ - { periodStart: PERIOD_START, periodEnd: new Date('2026-09-01T00:00:00.000Z') }, + { + periodStart: PERIOD_START, + periodEnd: new Date('2026-09-01T00:00:00.000Z'), + lastClosedPeriodStart: PERIOD_START, + }, ]) const terminal = await claimTerminalPeriod('sub-1') expect(terminal.periodStart).toEqual(PERIOD_START) + expect(terminal.markerWasCurrent).toBe(true) const markerSet = dbChainMockFns.set.mock.calls.find( (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date ) expect(markerSet).toBeDefined() }) + it('reports a lagging marker so the terminal settlement ignores the stale tracker', async () => { + queueTableRows(schemaMock.subscription, [ + { + periodStart: PERIOD_START, + periodEnd: new Date('2026-09-01T00:00:00.000Z'), + lastClosedPeriodStart: PREV_PERIOD_START, + }, + ]) + + const terminal = await claimTerminalPeriod('sub-1') + + expect(terminal.markerWasCurrent).toBe(false) + }) + it('returns nulls without claiming when the subscription has no period', async () => { queueTableRows(schemaMock.subscription, [{ periodStart: null, periodEnd: null }]) const terminal = await claimTerminalPeriod('sub-1') - expect(terminal).toEqual({ periodStart: null, periodEnd: null }) + expect(terminal).toEqual({ periodStart: null, periodEnd: null, markerWasCurrent: true }) expect(dbChainMockFns.set).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 430fcb8cbdf..e44fcdd31fb 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -196,25 +196,41 @@ export async function closeElapsedPeriodBeforeDeletion(subscriptionId: string): * including its outbox invoice — so deletion and sweep can never both bill * the same period. Call `closeElapsedPeriodBeforeDeletion` first so a lagging * elapsed period is settled rather than jumped. Returns the fresh period - * bounds for the deletion flow to settle against. + * bounds for the deletion flow to settle against, plus `markerWasCurrent`: + * whether the close marker had already caught up to the terminal period + * before this claim. The `billedOverageThisPeriod` tracker only ever holds + * collections for the period that began at the marker (the threshold gate + * blocks settlement whenever the marker lags), so the terminal settlement + * must ignore the tracker when the marker was still lagging — its contents + * belong to a forgiven elapsed period, never to the terminal window. */ -export async function claimTerminalPeriod( - subscriptionId: string -): Promise<{ periodStart: Date | null; periodEnd: Date | null }> { +export async function claimTerminalPeriod(subscriptionId: string): Promise<{ + periodStart: Date | null + periodEnd: Date | null + markerWasCurrent: boolean +}> { return db.transaction(async (tx) => { const [row] = await tx .select({ periodStart: subscriptionTable.periodStart, periodEnd: subscriptionTable.periodEnd, + lastClosedPeriodStart: subscriptionTable.lastClosedPeriodStart, }) .from(subscriptionTable) .where(eq(subscriptionTable.id, subscriptionId)) .for('update') .limit(1) - if (!row?.periodStart) return { periodStart: null, periodEnd: null } + if (!row?.periodStart) { + // Mirrors the threshold gate: a null `periodStart` cannot race a + // rollover, so any tracked collections are legitimately current. + return { periodStart: null, periodEnd: null, markerWasCurrent: true } + } + const markerWasCurrent = + !!row.lastClosedPeriodStart && + row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime() await claimCloseMarker(tx, subscriptionId, row.periodStart) - return { periodStart: row.periodStart, periodEnd: row.periodEnd } + return { periodStart: row.periodStart, periodEnd: row.periodEnd, markerWasCurrent } }) } @@ -514,7 +530,16 @@ export async function closeElapsedBillingPeriod( .where(eq(userStats.userId, trackerUserId)) .limit(1) - const alreadyBilled = toNumber(toDecimal(tracker?.billedOverageThisPeriod)) + // The tracker's collections belong to the period that began at the + // marker — the threshold gate blocks settlement whenever the marker + // lags, so nothing newer can be in it. When this close skipped + // forgiven periods (`closeFrom` advanced past the marker), those + // collections offset a forgiven period's overage, not this one's: + // count nothing against this close. The reset below still clears them. + const alreadyBilled = + closeFrom.getTime() === marker.getTime() + ? toNumber(toDecimal(tracker?.billedOverageThisPeriod)) + : 0 let remaining = Math.max(0, totalOverage - alreadyBilled) if (remaining > 0) { diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index ebfde387035..0fc6d64f14c 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -350,7 +350,15 @@ export async function handleSubscriptionDeleted( return { totalOverage: 0, kind: 'enterprise' as const } } - const billedOverage = await getBilledOverageForSubscription(subscription) + // The tracker only ever holds collections for the period that began + // at the close marker — the threshold gate blocks settlement while + // the marker lags. If the marker was still lagging at claim time + // (the elapsed close above deferred), the tracked amount belongs to + // that forgiven elapsed period, not the terminal window: subtracting + // it would under-bill the final invoice, so count nothing. + const billedOverage = terminal.markerWasCurrent + ? await getBilledOverageForSubscription(subscription) + : 0 const remainingOverage = Math.max(0, totalOverage - billedOverage) logger.info('Subscription deleted overage calculation', { From 866d3f05f31d875d2c89c125caa5c572b3642c0d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 25 Aug 2026 16:26:59 -0700 Subject: [PATCH 14/14] fix(billing): reject lagging terminal claims and thread billingInterval into deletion bookkeeping claimTerminalPeriod no longer advances the marker over an unclosed elapsed period: a lagging marker is reported without a write so the deletion handler can run the elapsed close once more (healing a rollover that committed between close and claim) and only then seal the marker explicitly, with an error log, when the period is genuinely unclosable. Sealing preserves the in-flight-sweep abort guarantee. Deletion bookkeeping now passes the subscription's billingInterval through, so an enterprise reporting subscription whose interval lives on the row column (not metadata) still resolves as reporting-anchored and keeps its bookkeeping no-op, matching every other resolver call site. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/billing/cycle-close.test.ts | 28 +++++++++++--- apps/sim/lib/billing/cycle-close.ts | 38 +++++++++++++++---- apps/sim/lib/billing/webhooks/subscription.ts | 18 +++++++-- 3 files changed, 66 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index ec4b6f16297..5a29cf9fdc2 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -550,7 +550,7 @@ describe('claimTerminalPeriod', () => { dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) }) - it('claims the marker from the fresh row period and returns it for settlement', async () => { + it('returns the fresh period without rewriting a current marker', async () => { queueTableRows(schemaMock.subscription, [ { periodStart: PERIOD_START, @@ -563,13 +563,10 @@ describe('claimTerminalPeriod', () => { expect(terminal.periodStart).toEqual(PERIOD_START) expect(terminal.markerWasCurrent).toBe(true) - const markerSet = dbChainMockFns.set.mock.calls.find( - (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date - ) - expect(markerSet).toBeDefined() + expect(dbChainMockFns.set).not.toHaveBeenCalled() }) - it('reports a lagging marker so the terminal settlement ignores the stale tracker', async () => { + it('reports a lagging marker without jumping it, so the caller can close and re-claim', async () => { queueTableRows(schemaMock.subscription, [ { periodStart: PERIOD_START, @@ -581,6 +578,25 @@ describe('claimTerminalPeriod', () => { const terminal = await claimTerminalPeriod('sub-1') expect(terminal.markerWasCurrent).toBe(false) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + }) + + it('seals a lagging marker on request, forgiving the unclosed period loudly', async () => { + queueTableRows(schemaMock.subscription, [ + { + periodStart: PERIOD_START, + periodEnd: new Date('2026-09-01T00:00:00.000Z'), + lastClosedPeriodStart: PREV_PERIOD_START, + }, + ]) + + const terminal = await claimTerminalPeriod('sub-1', { sealLagging: true }) + + expect(terminal.markerWasCurrent).toBe(false) + const markerSet = dbChainMockFns.set.mock.calls.find( + (call) => (call[0] as Record).lastClosedPeriodStart instanceof Date + ) + expect(markerSet).toBeDefined() }) it('returns nulls without claiming when the subscription has no period', async () => { diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index e44fcdd31fb..3573bb95824 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -197,14 +197,25 @@ export async function closeElapsedPeriodBeforeDeletion(subscriptionId: string): * the same period. Call `closeElapsedPeriodBeforeDeletion` first so a lagging * elapsed period is settled rather than jumped. Returns the fresh period * bounds for the deletion flow to settle against, plus `markerWasCurrent`: - * whether the close marker had already caught up to the terminal period - * before this claim. The `billedOverageThisPeriod` tracker only ever holds - * collections for the period that began at the marker (the threshold gate - * blocks settlement whenever the marker lags), so the terminal settlement - * must ignore the tracker when the marker was still lagging — its contents - * belong to a forgiven elapsed period, never to the terminal window. + * whether the close marker had already caught up to the terminal period. + * The `billedOverageThisPeriod` tracker only ever holds collections for the + * period that began at the marker (the threshold gate blocks settlement + * whenever the marker lags), so the terminal settlement must ignore the + * tracker when the marker was still lagging — its contents belong to a + * forgiven elapsed period, never to the terminal window. + * + * A lagging marker means an elapsed period is still unclosed — either the + * preceding close deferred, or a rollover committed between that close and + * this claim. By default the claim then leaves the marker untouched so the + * caller can run the close again and re-claim; `sealLagging` advances the + * marker over the unclosed period anyway (logging the forgiveness), which + * also guarantees an in-flight sweep that selected this subscription before + * its status changed aborts its own conflicting close. */ -export async function claimTerminalPeriod(subscriptionId: string): Promise<{ +export async function claimTerminalPeriod( + subscriptionId: string, + options: { sealLagging?: boolean } = {} +): Promise<{ periodStart: Date | null periodEnd: Date | null markerWasCurrent: boolean @@ -229,7 +240,17 @@ export async function claimTerminalPeriod(subscriptionId: string): Promise<{ const markerWasCurrent = !!row.lastClosedPeriodStart && row.lastClosedPeriodStart.getTime() >= row.periodStart.getTime() - await claimCloseMarker(tx, subscriptionId, row.periodStart) + if (!markerWasCurrent && options.sealLagging) { + logger.error( + 'Sealing an unclosed elapsed period at terminal claim; residual overage forgiven', + { + subscriptionId, + marker: row.lastClosedPeriodStart?.toISOString() ?? null, + periodStart: row.periodStart.toISOString(), + } + ) + await claimCloseMarker(tx, subscriptionId, row.periodStart) + } return { periodStart: row.periodStart, periodEnd: row.periodEnd, markerWasCurrent } }) } @@ -713,6 +734,7 @@ export async function writeFinalPeriodBookkeeping(sub: { id: string plan: string | null referenceId: string + billingInterval?: string | null periodStart?: Date | null periodEnd?: Date | null metadata?: unknown diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 0fc6d64f14c..f6528ca3906 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -260,6 +260,7 @@ export async function handleSubscriptionDeleted( referenceId: string stripeSubscriptionId: string | null seats?: number | null + billingInterval?: string | null periodStart?: Date | null periodEnd?: Date | null metadata?: unknown @@ -293,10 +294,17 @@ export async function handleSubscriptionDeleted( // Then claim the terminal period BEFORE computing or charging: this // reads the row's fresh period (webhook payloads can be stale across - // a rollover) and serializes with the cycle-close sweep — an - // in-flight close fails its guarded marker claim and rolls back, so - // both paths can never bill the same period. - const terminal = await claimTerminalPeriod(subscription.id) + // a rollover) and serializes with the cycle-close sweep. A lagging + // marker here means the close above deferred OR a rollover committed + // in between — run the close once more (it settles a freshly elapsed + // period; a deferred close defers again, loudly), then seal so the + // marker cannot be raced indefinitely and an in-flight sweep aborts + // its conflicting close. + let terminal = await claimTerminalPeriod(subscription.id) + if (!terminal.markerWasCurrent) { + await closeElapsedPeriodBeforeDeletion(subscription.id) + terminal = await claimTerminalPeriod(subscription.id, { sealLagging: true }) + } const settlementPeriod = { periodStart: terminal.periodStart ?? subscription.periodStart ?? null, periodEnd: terminal.periodEnd ?? subscription.periodEnd ?? null, @@ -313,6 +321,7 @@ export async function handleSubscriptionDeleted( id: subscription.id, plan: subscription.plan, referenceId: subscription.referenceId, + billingInterval: subscription.billingInterval ?? null, ...settlementPeriod, metadata: subscription.metadata, }) @@ -442,6 +451,7 @@ export async function handleSubscriptionDeleted( id: subscription.id, plan: subscription.plan, referenceId: subscription.referenceId, + billingInterval: subscription.billingInterval ?? null, ...settlementPeriod, metadata: subscription.metadata, })