improvement(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join, outbox service (#4219)

* fix(billing): route scope by subscription referenceId, sync plan from Stripe, transfer storage on org join

Route every billing decision (usage limits, credits, storage, rate
limit, threshold billing, webhooks, UI permissions) through the
subscription's `referenceId` instead of plan-name heuristics. Fixes
the production state where a `pro_6000` subscription attached to an
organization was treated as personal Pro by display/edit code while
execution correctly enforced the org cap.

Scope
- Add `isOrgScopedSubscription(sub, userId)` (pure) and
  `isSubscriptionOrgScoped(sub)` (async DB-backed) helpers. One is
  used wherever a user perspective is available; the other in webhook
  handlers that only have a subscription row.
- Replace plan-name scope checks in ~20 files: usage/limit readers,
  credits balance + purchase, threshold billing, storage limits +
  tracking, rate limiter, invoice + subscription webhooks, seat
  management, membership join/leave, `switch-plan` admin gate,
  admin credits/billing routes, copilot 402 handler, UI subscription
  settings + permissions + sidebar indicator, React Query types.

Plan sync
- Add `syncSubscriptionPlan(subscriptionId, currentPlan, planFromStripe)`
  called from `onSubscriptionComplete` and `onSubscriptionUpdate` so
  the DB `plan` column heals on every Stripe event. Pro->Team upgrades
  previously updated price, seats, and referenceId but left `plan`
  stale — this is what produced the `pro_6000`-on-org row.

Priority + grace period
- `getHighestPrioritySubscription` now prefers org over personal
  within each tier (Enterprise > Team > Pro, org > personal at each).
  A user with a `cancelAtPeriodEnd` personal Pro who joins a paid org
  routes pooled resources to the org through the grace window.
- `calculateSubscriptionOverage` personal-Pro branch reads user_stats
  directly (bypassing priority) and bills only `proPeriodCostSnapshot`
  when the user joined a paid org mid-cycle, so post-join org usage
  isn't double-charged on the personal Pro's final invoice.
  `resetUsageForSubscription` mirrors this: preserves
  `currentPeriodCost` / `currentPeriodCopilotCost` when
  `proPeriodCostSnapshot > 0` so the org's next cycle-close captures
  post-join usage correctly.

Uniform base-price formula
- `basePrice × (seats ?? 1)` everywhere: `getOrgUsageLimit`,
  `updateOrganizationUsageLimit`, `setUsageLimitForCredits`,
  `calculateSubscriptionOverage`, threshold billing,
  `syncSubscriptionUsageLimits`, `getOrganizationBillingData`.
  Admin dashboard math now agrees with enforcement math.

Storage transfer on join
- Invitation-accept flow moves `user_stats.storageUsedBytes` into
  `organization.storageUsedBytes` inside the same transaction when
  the org is paid.
- `syncSubscriptionUsageLimits` runs a bulk-backfill version so
  members who joined before this fix, or orgs that upgraded from
  free to paid after members joined, get pulled into the org pool
  on the next subscription event. Idempotent.

UX polish
- Copilot 402 handler differentiates personal-scoped ("increase your
  usage limit") from org-scoped ("ask an owner or admin to raise the
  limit") while keeping the `increase_limit` action code the parser
  already understands.
- Duplicate-subscription error on team upgrade names the existing
  plan via `getDisplayPlanName`.
- Invitation-accept invalidates subscription + organization React
  Query caches before redirect so settings doesn't flash the user's
  pre-join personal view.

Dead code removal
- Remove unused `calculateUserOverage`, and the following fields on
  `SubscriptionBillingData` / `getSimplifiedBillingSummary` that no
  consumer in the monorepo read: `basePrice`, `overageAmount`,
  `totalProjected`, `tierCredits`, `basePriceCredits`,
  `currentUsageCredits`, `overageAmountCredits`, `totalProjectedCredits`,
  `usageLimitCredits`, `currentCredits`, `limitCredits`,
  `lastPeriodCostCredits`, `lastPeriodCopilotCostCredits`,
  `copilotCostCredits`, and the `organizationData` subobject. Add
  `metadata: unknown` to match what the server returns.

Notes for the triggering customer
- The `pro_6000`-on-org row self-heals on the next Stripe event via
  `syncSubscriptionPlan`. For the one known customer, a direct
  UPDATE is sufficient:
  `UPDATE subscription SET plan='team_6000' WHERE id='aq2...' AND plan='pro_6000'`.

Made-with: Cursor

* fix tests

* address more comments

* progress

* harden further

* outbox service

* address comments

* address comment on check

* simplify

* cleanup code

* minor improvement
This commit is contained in:
Vikhyath Mondreti
2026-04-18 10:46:14 -07:00
committed by GitHub
parent 28b4c4cc67
commit c246f5c660
57 changed files with 18336 additions and 1907 deletions
+14 -11
View File
@@ -7,8 +7,6 @@ import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'
const logger = createLogger('UnifiedBillingAPI')
@@ -47,7 +45,20 @@ export async function GET(request: NextRequest) {
let billingData
if (context === 'user') {
// Get user billing and billing blocked status in parallel
if (contextId) {
const membership = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, contextId), eq(member.userId, session.user.id)))
.limit(1)
if (membership.length === 0) {
return NextResponse.json(
{ error: 'Access denied - not a member of this organization' },
{ status: 403 }
)
}
}
const [billingResult, billingStatus] = await Promise.all([
getSimplifiedBillingSummary(session.user.id, contextId || undefined),
getEffectiveBillingStatus(session.user.id),
@@ -107,7 +118,6 @@ export async function GET(request: NextRequest) {
)
}
// Transform data to match component expectations
billingData = {
organizationId: rawBillingData.organizationId,
organizationName: rawBillingData.organizationName,
@@ -122,17 +132,10 @@ export async function GET(request: NextRequest) {
averageUsagePerMember: rawBillingData.averageUsagePerMember,
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
tierCredits: getPlanTierCredits(rawBillingData.subscriptionPlan),
totalCurrentUsageCredits: dollarsToCredits(rawBillingData.totalCurrentUsage),
totalUsageLimitCredits: dollarsToCredits(rawBillingData.totalUsageLimit),
minimumBillingAmountCredits: dollarsToCredits(rawBillingData.minimumBillingAmount),
averageUsagePerMemberCredits: dollarsToCredits(rawBillingData.averageUsagePerMember),
members: rawBillingData.members.map((m) => ({
...m,
joinedAt: m.joinedAt.toISOString(),
lastActive: m.lastActive?.toISOString() || null,
currentUsageCredits: dollarsToCredits(m.currentUsage),
usageLimitCredits: dollarsToCredits(m.usageLimit),
})),
}
@@ -9,12 +9,13 @@ import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { writeBillingInterval } from '@/lib/billing/core/subscription'
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getPlanType, isEnterprise } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
hasUsableSubscriptionAccess,
hasUsableSubscriptionStatus,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { toError } from '@/lib/core/utils/helpers'
@@ -93,7 +94,7 @@ export async function POST(request: NextRequest) {
)
}
if (isOrgPlan(sub.plan)) {
if (isOrgScopedSubscription(sub, userId)) {
const hasPermission = await isOrganizationOwnerOrAdmin(userId, sub.referenceId)
if (!hasPermission) {
return NextResponse.json({ error: 'Only team admins can change the plan' }, { status: 403 })
@@ -14,7 +14,7 @@ import {
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
@@ -22,9 +22,10 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
@@ -328,8 +329,6 @@ export async function PUT(
}
}
let personalProToCancel: any = null
await db.transaction(async (tx) => {
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))
@@ -342,8 +341,7 @@ export async function PUT(
createdAt: new Date(),
})
// Snapshot Pro usage and cancel Pro subscription when joining a paid team
try {
{
const orgSubs = await tx
.select()
.from(subscriptionTable)
@@ -356,7 +354,7 @@ export async function PUT(
.limit(1)
const orgSub = orgSubs[0]
const orgIsPaid = orgSub && isOrgPlan(orgSub.plan)
const orgIsPaid = orgSub && isPaid(orgSub.plan)
if (orgIsPaid) {
const userId = session.user.id
@@ -393,8 +391,9 @@ export async function PUT(
.update(userStats)
.set({
proPeriodCostSnapshot: currentProUsage,
currentPeriodCost: '0', // Reset so new usage is attributed to team
currentPeriodCopilotCost: '0', // Reset copilot cost for new period
proPeriodCostSnapshotAt: new Date(),
currentPeriodCost: '0',
currentPeriodCopilotCost: '0',
})
.where(eq(userStats.userId, userId))
@@ -405,19 +404,48 @@ export async function PUT(
})
}
// Mark for cancellation after transaction
if (personalPro.cancelAtPeriodEnd !== true) {
personalProToCancel = personalPro
if (personalPro.cancelAtPeriodEnd !== true && personalPro.stripeSubscriptionId) {
await tx
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscriptionTable.id, personalPro.id))
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
stripeSubscriptionId: personalPro.stripeSubscriptionId,
subscriptionId: personalPro.id,
reason: 'member-joined-paid-org',
})
}
}
const storageRows = await tx
.select({ storageUsedBytes: userStats.storageUsedBytes })
.from(userStats)
.where(eq(userStats.userId, userId))
.for('update')
.limit(1)
const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0
if (bytesToTransfer > 0) {
await tx
.update(organization)
.set({
storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`,
})
.where(eq(organization.id, organizationId))
await tx
.update(userStats)
.set({ storageUsedBytes: 0 })
.where(eq(userStats.userId, userId))
logger.info('Transferred personal storage bytes to org pool on join', {
userId,
organizationId,
bytes: bytesToTransfer,
})
}
}
} catch (error) {
logger.error('Failed to handle Pro user joining team', {
userId: session.user.id,
organizationId,
error,
})
// Don't fail the whole invitation acceptance due to this
}
// Auto-assign to permission group if one has autoAddNewMembers enabled
@@ -557,44 +585,6 @@ export async function PUT(
}
}
// Handle Pro subscription cancellation after transaction commits
if (personalProToCancel) {
try {
const stripe = requireStripeClient()
if (personalProToCancel.stripeSubscriptionId) {
try {
await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, {
cancel_at_period_end: true,
})
} catch (stripeError) {
logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', {
userId: session.user.id,
subscriptionId: personalProToCancel.id,
stripeSubscriptionId: personalProToCancel.stripeSubscriptionId,
error: stripeError,
})
}
}
await db
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscriptionTable.id, personalProToCancel.id))
logger.info('Auto-cancelled personal Pro at period end after joining paid team', {
userId: session.user.id,
personalSubscriptionId: personalProToCancel.id,
organizationId,
})
} catch (dbError) {
logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', {
userId: session.user.id,
subscriptionId: personalProToCancel.id,
error: dbError,
})
}
}
if (status === 'accepted') {
try {
await syncUsageLimitsFromSubscription(session.user.id)
@@ -1,12 +1,19 @@
import { db } from '@sim/db'
import { invitation, member, organization, user, userStats } from '@sim/db/schema'
import {
invitation,
member,
organization,
subscription as subscriptionTable,
user,
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
@@ -83,16 +90,32 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.leftJoin(userStats, eq(user.id, userStats.userId))
.where(eq(member.organizationId, organizationId))
const membersWithUsage = await Promise.all(
base.map(async (row) => {
const usage = await getUserUsageData(row.userId)
return {
...row,
billingPeriodStart: usage.billingPeriodStart,
billingPeriodEnd: usage.billingPeriodEnd,
}
// The billing period is the same for every member — it comes from
// whichever subscription covers them. Fetch once and attach to
// every row instead of calling `getUserUsageData` per-member,
// which would run an O(N) pooled query for each of N rows.
const [orgSub] = await db
.select({
periodStart: subscriptionTable.periodStart,
periodEnd: subscriptionTable.periodEnd,
})
)
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
const billingPeriodStart = orgSub?.periodStart ?? null
const billingPeriodEnd = orgSub?.periodEnd ?? null
const membersWithUsage = base.map((row) => ({
...row,
billingPeriodStart,
billingPeriodEnd,
}))
return NextResponse.json({
success: true,
@@ -13,6 +13,8 @@ import {
hasUsableSubscriptionStatus,
USABLE_SUBSCRIPTION_STATUSES,
} from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('OrganizationSeatsAPI')
@@ -164,8 +166,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
userId: session.user.id,
})
// Update the subscription item quantity using Stripe's recommended approach
// This will automatically prorate the billing
const updatedSubscription = await stripe.subscriptions.update(
orgSubscription.stripeSubscriptionId,
{
@@ -176,19 +176,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
},
],
proration_behavior: 'always_invoice',
}
},
{ idempotencyKey: `seats-update:${orgSubscription.stripeSubscriptionId}:${newSeatCount}` }
)
// Update our local database to reflect the change
// Note: This will also be updated via webhook, but we update immediately for UX
await db
.update(subscription)
.set({
seats: newSeatCount,
})
.where(eq(subscription.id, orgSubscription.id))
await syncSeatsFromStripeQuantity(
orgSubscription.id,
orgSubscription.seats,
updatedSubscription.items.data[0]?.quantity ?? newSeatCount
)
// Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum)
const { basePrice } = getPlanPricing(orgSubscription.plan)
const newMinimumLimit = newSeatCount * basePrice
@@ -200,7 +197,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const currentOrgLimit =
orgData.length > 0 && orgData[0].orgUsageLimit
? Number.parseFloat(orgData[0].orgUsageLimit)
? toNumber(toDecimal(orgData[0].orgUsageLimit))
: 0
// Update if new minimum is higher than current limit
+4 -2
View File
@@ -30,10 +30,11 @@ import { and, eq, inArray } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import { isPaid } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getEffectiveSeats,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { generateShortId } from '@/lib/core/utils/uuid'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
@@ -110,7 +111,8 @@ export const POST = withAdminAuth(async (request) => {
const plan = userSubscription.plan
let seats: number | null = null
if (isOrgPlan(plan)) {
// Route admin credits to the subscription's entity (org if org-scoped).
if (isOrgScopedSubscription(userSubscription, resolvedUserId)) {
entityType = 'organization'
entityId = userSubscription.referenceId
@@ -33,7 +33,6 @@ import { member, organization, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -229,28 +228,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse(result.error || 'Failed to add member')
}
if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
try {
const stripe = requireStripeClient()
await stripe.subscriptions.update(
result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
{ cancel_at_period_end: true }
)
logger.info('Admin API: Synced Pro cancellation with Stripe', {
userId: body.userId,
subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId,
stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
})
} catch (stripeError) {
logger.error('Admin API: Failed to sync Pro cancellation with Stripe', {
userId: body.userId,
subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId,
stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
error: stripeError,
})
}
}
const data: AdminMember = {
id: result.memberId!,
userId: body.userId,
@@ -0,0 +1,71 @@
import { db } from '@sim/db'
import { outboxEvent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
const logger = createLogger('AdminOutboxRequeueAPI')
export const dynamic = 'force-dynamic'
/**
* POST /api/v1/admin/outbox/[id]/requeue
*
* Move a dead-lettered outbox event back to `pending` so the worker
* will retry it. Resets `attempts`, `lastError`, and `availableAt` so
* the next poll picks it up. Only dead-lettered events can be
* requeued — completed/pending/processing rows are rejected to avoid
* operator errors.
*/
export const POST = withAdminAuthParams<{ id: string }>(async (_request, { params }) => {
const { id } = await params
try {
const result = await db
.update(outboxEvent)
.set({
status: 'pending',
attempts: 0,
lastError: null,
availableAt: new Date(),
lockedAt: null,
processedAt: null,
})
.where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter')))
.returning({ id: outboxEvent.id, eventType: outboxEvent.eventType })
if (result.length === 0) {
return NextResponse.json(
{
success: false,
error:
'Event not found or not in dead_letter status. Only dead-lettered events can be requeued.',
},
{ status: 404 }
)
}
logger.info('Requeued dead-lettered outbox event', {
eventId: result[0].id,
eventType: result[0].eventType,
})
return NextResponse.json({
success: true,
requeued: result[0],
})
} catch (error) {
logger.error('Failed to requeue outbox event', {
eventId: id,
error: error instanceof Error ? error.message : error,
})
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
})
+91
View File
@@ -0,0 +1,91 @@
import { db } from '@sim/db'
import { outboxEvent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
const logger = createLogger('AdminOutboxAPI')
export const dynamic = 'force-dynamic'
/**
* GET /api/v1/admin/outbox?status=dead_letter&eventType=...&limit=100
*
* Inspect outbox events for operator triage. Primary use: list
* dead-lettered rows to reconcile Stripe state manually after a
* permanent handler failure (e.g. Stripe account frozen, subscription
* already canceled by another path, etc.).
*
* Filters:
* - `status`: 'pending' | 'processing' | 'completed' | 'dead_letter' (default 'dead_letter')
* - `eventType`: exact match on event_type
* - `limit`: cap rows returned (default 100, max 500)
*
* Response includes aggregate counts by status for quick health read.
*/
export const GET = withAdminAuth(async (request: NextRequest) => {
try {
const { searchParams } = new URL(request.url)
const validStatuses = ['pending', 'processing', 'completed', 'dead_letter'] as const
const status = (searchParams.get('status') ?? 'dead_letter') as (typeof validStatuses)[number]
if (!validStatuses.includes(status)) {
return NextResponse.json(
{
success: false,
error: `Invalid status. Must be one of: ${validStatuses.join(', ')}`,
},
{ status: 400 }
)
}
const eventType = searchParams.get('eventType')
const rawLimit = searchParams.get('limit')
const parsedLimit = rawLimit === null ? 100 : Number.parseInt(rawLimit, 10)
const limit =
Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.min(500, Math.max(1, parsedLimit))
: 100
const whereConditions = [eq(outboxEvent.status, status)]
if (eventType) {
whereConditions.push(eq(outboxEvent.eventType, eventType))
}
const rows = await db
.select()
.from(outboxEvent)
.where(and(...whereConditions))
.orderBy(desc(outboxEvent.createdAt))
.limit(limit)
// Aggregate counts per (status, eventType) for at-a-glance health.
const counts = await db
.select({
status: outboxEvent.status,
eventType: outboxEvent.eventType,
count: sql<number>`count(*)::int`,
})
.from(outboxEvent)
.groupBy(outboxEvent.status, outboxEvent.eventType)
return NextResponse.json({
success: true,
filter: { status, eventType, limit },
rows,
counts,
})
} catch (error) {
logger.error('Failed to list outbox events', {
error: error instanceof Error ? error.message : error,
})
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
})
@@ -28,6 +28,8 @@ import { subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -91,28 +93,31 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return badRequestResponse('Subscription has no Stripe subscription ID')
}
const stripe = requireStripeClient()
if (atPeriodEnd) {
// Schedule cancellation at period end
await stripe.subscriptions.update(existing.stripeSubscriptionId, {
cancel_at_period_end: true,
await db.transaction(async (tx) => {
await tx
.update(subscription)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscription.id, subscriptionId))
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
stripeSubscriptionId: existing.stripeSubscriptionId,
subscriptionId: existing.id,
reason: reason ?? 'admin-cancel-at-period-end',
})
})
// Update DB (webhooks don't sync cancelAtPeriodEnd)
await db
.update(subscription)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscription.id, subscriptionId))
logger.info('Admin API: Scheduled subscription cancellation at period end', {
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
plan: existing.plan,
referenceId: existing.referenceId,
periodEnd: existing.periodEnd,
reason,
})
logger.info(
'Admin API: Scheduled subscription cancellation at period end (DB committed, Stripe queued)',
{
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
plan: existing.plan,
referenceId: existing.referenceId,
periodEnd: existing.periodEnd,
reason,
}
)
return singleResponse({
success: true,
@@ -124,11 +129,16 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
})
}
// Immediate cancellation
await stripe.subscriptions.cancel(existing.stripeSubscriptionId, {
prorate: true,
invoice_now: true,
})
// Immediate cancellation — stays synchronous. Stripe's
// `customer.subscription.deleted` webhook triggers full cleanup
// (overage bill, usage reset, Pro restore, org delete) via
// `handleSubscriptionDeleted`, so no outbox needed here.
const stripe = requireStripeClient()
await stripe.subscriptions.cancel(
existing.stripeSubscriptionId,
{ prorate: true, invoice_now: true },
{ idempotencyKey: `admin-cancel:${existing.stripeSubscriptionId}` }
)
logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', {
subscriptionId,
@@ -23,7 +23,7 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch
import { createLogger } from '@sim/logger'
import { eq, or } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { generateShortId } from '@/lib/core/utils/uuid'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
@@ -155,7 +155,7 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
.limit(1)
const userSubscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan)
const isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId)
const [orgMembership] = await db
.select({ organizationId: member.organizationId })
@@ -168,9 +168,9 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
const warnings: string[] = []
if (body.currentUsageLimit !== undefined) {
if (isTeamOrEnterpriseMember && orgMembership) {
if (isOrgScopedMember && orgMembership) {
warnings.push(
'User is a team/enterprise member. Individual limits may be ignored in favor of organization limits.'
'User is on an org-scoped subscription. Individual limits are ignored in favor of organization limits.'
)
}
@@ -0,0 +1,49 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
import { processOutboxEvents } from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('OutboxProcessorAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 120
const handlers = {
...billingOutboxHandlers,
} as const
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const authError = verifyCronAuth(request, 'Outbox processor')
if (authError) {
return authError
}
const result = await processOutboxEvents(handlers, { batchSize: 20 })
logger.info('Outbox processing completed', { requestId, ...result })
return NextResponse.json({
success: true,
requestId,
result,
})
} catch (error) {
logger.error('Outbox processing failed', {
requestId,
error: error instanceof Error ? error.message : error,
})
return NextResponse.json(
{
success: false,
requestId,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
+14
View File
@@ -2,9 +2,12 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth/auth-client'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { organizationKeys } from '@/hooks/queries/organization'
import { subscriptionKeys } from '@/hooks/queries/subscription'
const logger = createLogger('InviteById')
@@ -166,6 +169,7 @@ export default function Invite() {
const inviteId = params.id as string
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const queryClient = useQueryClient()
const [invitationDetails, setInvitationDetails] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<InviteError | null>(null)
@@ -345,6 +349,16 @@ export default function Invite() {
organizationId: orgId,
})
// Invalidate billing / org caches so `/workspace` doesn't flash the
// user's pre-join personal subscription while the new team-scoped
// data is being refetched. Accept-flow side effects (snapshot,
// storage transfer, plan sync, member insert) have already
// committed by the time we reach here.
await Promise.all([
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }),
queryClient.invalidateQueries({ queryKey: organizationKeys.all }),
])
setAccepted(true)
setTimeout(() => {
@@ -17,6 +17,12 @@ export interface SubscriptionState {
isTeam: boolean
isEnterprise: boolean
isPaid: boolean
/**
* True when the subscription's `referenceId` is an organization. Source
* of truth for scope-based decisions — `pro_*` plans that have been
* transferred to an org are org-scoped even though `isTeam` is false.
*/
isOrgScoped: boolean
plan: string
status: string
}
@@ -30,21 +36,27 @@ export function getSubscriptionPermissions(
subscription: SubscriptionState,
userRole: UserRole
): SubscriptionPermissions {
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription
const { isTeamAdmin } = userRole
// Non-admin org members see the "team member" view: no edit / no cancel
// / no upgrade, pooled usage display.
const orgMemberOnly = isOrgScoped && !isTeamAdmin
const orgAdminOrSolo = !isOrgScoped || isTeamAdmin
const isEnterpriseMember = isEnterprise && !isTeamAdmin
const canViewUsageInfo = !isEnterpriseMember
return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isTeam),
canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members
canManageTeam: isTeam && isTeamAdmin,
canEditUsageLimit: (isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users and team admins see pencil
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
showTeamMemberView: isTeam && !isTeamAdmin,
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
canUpgradeToTeam: isFree || (isPro && !isOrgScoped),
canViewEnterprise: !isEnterprise && !orgMemberOnly,
canManageTeam: isOrgScoped && isTeamAdmin && !isEnterprise,
canEditUsageLimit: (isFree || (isPaid && !isEnterprise)) && orgAdminOrSolo,
canCancelSubscription: isPaid && !isEnterprise && orgAdminOrSolo,
showTeamMemberView: orgMemberOnly,
showUpgradePlans:
(isFree || (isPro && !isOrgScoped) || (isOrgScoped && isTeamAdmin)) && !isEnterprise,
isEnterpriseMember,
canViewUsageInfo,
}
@@ -55,22 +67,16 @@ export function getVisiblePlans(
userRole: UserRole
): ('pro' | 'team' | 'enterprise')[] {
const plans: ('pro' | 'team' | 'enterprise')[] = []
const { isFree, isPro, isTeam } = subscription
const { isFree, isPro, isEnterprise, isOrgScoped } = subscription
const { isTeamAdmin } = userRole
// Free users see all plans
if (isFree) {
plans.push('pro', 'team', 'enterprise')
}
// Pro users see team and enterprise
else if (isPro && !isTeam) {
} else if (isPro && !isOrgScoped) {
plans.push('team', 'enterprise')
}
// Team owners see only enterprise (no team plan since they already have it)
else if (isTeam && isTeamAdmin) {
} else if (isOrgScoped && isTeamAdmin && !isEnterprise) {
plans.push('enterprise')
}
// Team members, Enterprise users see no plans
return plans
}
@@ -34,7 +34,6 @@ import {
getPlanTierDollars,
isEnterprise,
isFree,
isOrgPlan,
isPaid,
isPro,
isTeam,
@@ -294,12 +293,12 @@ export function Subscription() {
const usageLimitRef = useRef<UsageLimitRef | null>(null)
const hasInitializedInterval = useRef(false)
const hasOrgPlan = isOrgPlan(subscriptionData?.data?.plan)
const hasOrgScopedSubscription = Boolean(subscriptionData?.data?.isOrgScoped)
const isLoading =
isSubscriptionLoading ||
isUsageLimitLoading ||
isWorkspaceLoading ||
(hasOrgPlan && isOrgBillingLoading)
(hasOrgScopedSubscription && isOrgBillingLoading)
const isCancelledAtPeriodEnd = subscriptionData?.data?.cancelAtPeriodEnd === true
@@ -311,6 +310,12 @@ export function Subscription() {
isPaid:
isPaid(subscriptionData?.data?.plan) &&
hasPaidSubscriptionStatus(subscriptionData?.data?.status),
/**
* True when the subscription is attached to an org (regardless of plan
* name). Drives routing of usage-limit edits and whether we show pooled
* or personal usage.
*/
isOrgScoped: Boolean(subscriptionData?.data?.isOrgScoped),
plan: subscriptionData?.data?.plan || 'free',
status: subscriptionData?.data?.status || 'inactive',
seats: getEffectiveSeats(subscriptionData?.data),
@@ -364,16 +369,12 @@ export function Subscription() {
const isTeamAdmin = ['owner', 'admin'].includes(userRole)
const planIncludedAmount =
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: getPlanTierCredits(subscription.plan) / CREDIT_MULTIPLIER
const effectiveUsageLimit =
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
@@ -381,8 +382,7 @@ export function Subscription() {
subscription.isPaid && planIncludedAmount > 0 && effectiveUsageLimit > planIncludedAmount
const effectiveCurrentUsage =
(subscription.isTeam || subscription.isEnterprise) &&
organizationBillingData?.data?.totalCurrentUsage != null
subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null
? organizationBillingData.data.totalCurrentUsage
: usage.current
@@ -390,8 +390,7 @@ export function Subscription() {
const handleToggleOnDemand = useCallback(async () => {
try {
const isOrgContext =
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin && activeOrgId
const isOrgContext = subscription.isOrgScoped && isTeamAdmin && activeOrgId
if (isOnDemandActive) {
if (!canDisableOnDemand) return
@@ -420,8 +419,7 @@ export function Subscription() {
}, [
isOnDemandActive,
canDisableOnDemand,
subscription.isTeam,
subscription.isEnterprise,
subscription.isOrgScoped,
isTeamAdmin,
activeOrgId,
planIncludedAmount,
@@ -435,6 +433,7 @@ export function Subscription() {
isTeam: subscription.isTeam,
isEnterprise: subscription.isEnterprise,
isPaid: subscription.isPaid,
isOrgScoped: subscription.isOrgScoped,
plan: subscription.plan || 'free',
status: subscription.status || 'inactive',
},
@@ -448,6 +447,7 @@ export function Subscription() {
isTeam: subscription.isTeam,
isEnterprise: subscription.isEnterprise,
isPaid: subscription.isPaid,
isOrgScoped: subscription.isOrgScoped,
plan: subscription.plan || 'free',
status: subscription.status || 'inactive',
},
@@ -502,7 +502,7 @@ export function Subscription() {
return
}
if (isBlocked) {
const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
const context = subscription.isOrgScoped ? 'organization' : 'user'
openBillingPortal.mutate(
{
context,
@@ -529,8 +529,7 @@ export function Subscription() {
isDispute,
isBlocked,
subscription.isFree,
subscription.isTeam,
subscription.isEnterprise,
subscription.isOrgScoped,
activeOrgId,
doUpgrade,
logger,
@@ -591,13 +590,12 @@ export function Subscription() {
: undefined
}
current={
(subscription.isTeam || subscription.isEnterprise) &&
organizationBillingData?.data?.totalCurrentUsage != null
subscription.isOrgScoped && organizationBillingData?.data?.totalCurrentUsage != null
? organizationBillingData.data.totalCurrentUsage
: usage.current
}
limit={
subscription.isEnterprise || subscription.isTeam
subscription.isOrgScoped
? organizationBillingData?.data?.totalUsageLimit
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
@@ -612,31 +610,19 @@ export function Subscription() {
<UsageLimit
ref={usageLimitRef}
currentLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: usageLimitData.minimumLimit
}
context={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? 'organization'
: 'user'
}
organizationId={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? activeOrgId
: undefined
}
context={subscription.isOrgScoped && isTeamAdmin ? 'organization' : 'user'}
organizationId={subscription.isOrgScoped && isTeamAdmin ? activeOrgId : undefined}
onLimitUpdated={() => logger.info('Usage limit updated')}
/>
) : undefined
@@ -905,7 +891,7 @@ export function Subscription() {
setManagePlanModalOpen(false)
if (!betterAuthSubscription.cancel) return
try {
const isOrgSub = (subscription.isTeam || subscription.isEnterprise) && activeOrgId
const isOrgSub = subscription.isOrgScoped && activeOrgId
const referenceId = isOrgSub ? activeOrgId : session?.user?.id || ''
const returnUrl = getBaseUrl() + window.location.pathname
await betterAuthSubscription.cancel({ returnUrl, referenceId })
@@ -917,7 +903,7 @@ export function Subscription() {
onRestore={async () => {
if (!betterAuthSubscription.restore) return
try {
const isOrgSub = (subscription.isTeam || subscription.isEnterprise) && activeOrgId
const isOrgSub = subscription.isOrgScoped && activeOrgId
const referenceId = isOrgSub ? activeOrgId : session?.user?.id || ''
await betterAuthSubscription.restore({ referenceId })
await refetchSubscription()
@@ -937,9 +923,7 @@ export function Subscription() {
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={hasUsablePaidAccess && permissions.canEditUsageLimit}
entityType={
subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
}
entityType={subscription.isOrgScoped ? 'organization' : 'user'}
isLoading={isLoading}
onPurchaseComplete={() => refetchSubscription()}
/>
@@ -974,8 +958,7 @@ export function Subscription() {
disabled={openBillingPortal.isPending}
onClick={() => {
const portalWindow = window.open('', '_blank')
const context =
subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
const context = subscription.isOrgScoped ? 'organization' : 'user'
openBillingPortal.mutate(
{
context,
@@ -86,17 +86,28 @@ interface StatusTextConfig {
}
/**
* Determines if user can manage billing based on plan type and org role.
* Determines if user can manage billing based on plan type, subscription
* scope, and org role.
*
* @param planType - The user's current plan type
* When the subscription is org-scoped (any subscription whose referenceId
* points at an organization — includes `pro_*` plans transferred to an
* org, not just team/enterprise), only owners/admins can manage billing.
* Otherwise any free/pro user can manage their own.
*
* @param planType - The user's current plan type (for display category)
* @param orgRole - The user's role in the organization, if applicable
* @param isOrgScoped - Whether the subscription is attached to an org
* @returns True if the user has billing management permissions
*/
function canManageBilling(planType: PlanType, orgRole: OrgRole | null): boolean {
if (planType === 'free' || planType === 'pro') return true
if (planType === 'team' || planType === 'enterprise') {
function canManageBilling(
planType: PlanType,
orgRole: OrgRole | null,
isOrgScoped: boolean
): boolean {
if (isOrgScoped || planType === 'team' || planType === 'enterprise') {
return orgRole === 'owner' || orgRole === 'admin'
}
if (planType === 'free' || planType === 'pro') return true
return false
}
@@ -247,7 +258,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const isCritical = isBlocked || progressPercentage >= USAGE_THRESHOLDS.CRITICAL
const isWarning = !isCritical && progressPercentage >= USAGE_THRESHOLDS.WARNING
const userCanManageBilling = canManageBilling(planType, orgRole)
const isOrgScoped = Boolean(subscriptionData?.data?.isOrgScoped)
const userCanManageBilling = canManageBilling(planType, orgRole, isOrgScoped)
const displayState: DisplayState = {
planType,
@@ -323,17 +335,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const contextMenuItems = useMemo(
() => ({
// Set limit: Only for Pro and Team admins (not free, not enterprise)
showSetLimit: (isPro || (isTeam && userCanManageBilling)) && !isEnterprise,
// Upgrade to Pro: Only for free users
showSetLimit: userCanManageBilling && !isFree && !isEnterprise,
showUpgradeToPro: isFree,
// Upgrade to Team: Free users and Pro users with billing permission
showUpgradeToTeam: isFree || (isPro && userCanManageBilling),
// Manage seats: Only for Team admins
showManageSeats: isTeam && userCanManageBilling,
// Upgrade to Enterprise: Only for Team admins (not free, not pro, not enterprise)
showUpgradeToEnterprise: isTeam && userCanManageBilling,
// Contact support: Only for Enterprise admins
showContactSupport: isEnterprise && userCanManageBilling,
onSetLimit: handleSetLimit,
onUpgradeToPro: handleUpgradeToPro,
@@ -435,7 +441,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
if (isBlocked && userCanManageBilling) {
try {
const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
const context = isOrgScoped ? 'organization' : 'user'
const organizationId = subscriptionData?.data?.organization?.id
const response = await fetch('/api/billing/portal', {
+13 -7
View File
@@ -1,8 +1,9 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/auth/auth-client'
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers'
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { subscriptionKeys } from '@/hooks/queries/subscription'
const logger = createLogger('OrganizationQueries')
@@ -87,13 +88,17 @@ async function fetchOrganizationSubscription(orgId: string, _signal?: AbortSigna
return null
}
const teamSubscription = response.data?.find(
(sub: any) => hasPaidSubscriptionStatus(sub.status) && isTeam(sub.plan)
// Any paid subscription attached to the org counts as its active sub.
// Priority: Enterprise > Team > Pro (matches `getHighestPrioritySubscription`).
// This intentionally includes `pro_*` plans that have been transferred
// to the org — they are pooled org-scoped subscriptions.
const entitled = (response.data || []).filter(
(sub: any) => hasPaidSubscriptionStatus(sub.status) && isPaid(sub.plan)
)
const enterpriseSubscription = response.data?.find(
(sub: any) => hasPaidSubscriptionStatus(sub.status) && isEnterprise(sub.plan)
)
const activeSubscription = enterpriseSubscription || teamSubscription
const enterpriseSubscription = entitled.find((sub: any) => isEnterprise(sub.plan))
const teamSubscription = entitled.find((sub: any) => isTeam(sub.plan))
const proSubscription = entitled.find((sub: any) => !isEnterprise(sub.plan) && !isTeam(sub.plan))
const activeSubscription = enterpriseSubscription || teamSubscription || proSubscription
return activeSubscription || null
}
@@ -326,6 +331,7 @@ export function useRemoveMember() {
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
},
})
}
+10 -24
View File
@@ -17,11 +17,6 @@ export interface BillingUsageData {
lastPeriodCopilotCost: number
daysRemaining: number
copilotCost: number
currentCredits: number
limitCredits: number
lastPeriodCostCredits: number
lastPeriodCopilotCostCredits: number
copilotCostCredits: number
}
/**
@@ -30,10 +25,7 @@ export interface BillingUsageData {
export interface SubscriptionBillingData {
type: 'individual' | 'organization'
plan: string
basePrice: number
currentUsage: number
overageAmount: number
totalProjected: number
usageLimit: number
percentUsed: number
isWarning: boolean
@@ -41,18 +33,22 @@ export interface SubscriptionBillingData {
daysRemaining: number
creditBalance: number
billingInterval: 'month' | 'year'
tierCredits: number
basePriceCredits: number
currentUsageCredits: number
overageAmountCredits: number
totalProjectedCredits: number
usageLimitCredits: number
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
/**
* Whether the subscription is attached to an organization. Includes
* `pro_*` plans that have been transferred to an org; use this for
* scope-based decisions instead of `isTeam` / `isEnterprise`.
*/
isOrgScoped: boolean
/** Present when `isOrgScoped` is true. */
organizationId: string | null
status: string | null
seats: number | null
/** Raw subscription metadata JSON from Stripe (e.g. billingInterval). */
metadata: unknown
stripeSubscriptionId: string | null
periodEnd: string | null
cancelAtPeriodEnd?: boolean
@@ -61,16 +57,6 @@ export interface SubscriptionBillingData {
billingBlockedReason?: 'payment_failed' | 'dispute' | null
blockedByOrgOwner?: boolean
organization?: { id: string; role: 'owner' | 'admin' | 'member' }
organizationData?: {
seatCount: number
memberCount: number
totalBasePrice: number
totalCurrentUsage: number
totalOverage: number
totalBasePriceCredits: number
totalCurrentUsageCredits: number
totalOverageCredits: number
}
}
/**
+15 -2
View File
@@ -37,7 +37,7 @@ import {
} from '@/lib/auth/cimd'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import { writeBillingInterval } from '@/lib/billing/core/subscription'
import { syncSubscriptionPlan, writeBillingInterval } from '@/lib/billing/core/subscription'
import { handleNewUser } from '@/lib/billing/core/usage'
import {
ensureOrganizationForTeamSubscription,
@@ -2904,6 +2904,9 @@ export const auth = betterAuth({
{ subscriptionId: subscription.id, dbPlan: subscription.plan, priceId }
)
}
await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe)
const subscriptionForOrg = {
...subscription,
plan: planFromStripe ?? subscription.plan,
@@ -2981,6 +2984,9 @@ export const auth = betterAuth({
{ subscriptionId: subscription.id, dbPlan: subscription.plan }
)
}
await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe)
const subscriptionForOrg = {
...subscription,
plan: planFromStripe ?? subscription.plan,
@@ -3058,6 +3064,7 @@ export const auth = betterAuth({
await writeBillingInterval(resolvedSubscription.id, isAnnual ? 'year' : 'month')
},
onSubscriptionDeleted: async ({
event,
subscription,
}: {
event: Stripe.Event
@@ -3065,18 +3072,24 @@ export const auth = betterAuth({
subscription: any
}) => {
logger.info('[onSubscriptionDeleted] Subscription deleted', {
eventId: event.id,
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
})
try {
await handleSubscriptionDeleted(subscription)
await handleSubscriptionDeleted(subscription, event.id)
} catch (error) {
logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', {
eventId: event.id,
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
error,
})
// Rethrow so the Stripe webhook retries — otherwise
// the final overage invoice, usage reset, org cleanup,
// and personal Pro restore can be permanently skipped.
throw error
}
},
},
+13 -18
View File
@@ -1,29 +1,31 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { hasPaidSubscription } from '@/lib/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('BillingAuthorization')
/**
* Check if a user is authorized to manage billing for a given reference ID
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
* Check if a user is authorized to manage billing for a given reference ID.
* Reference ID can be either a user ID (personal subscription) or an
* organization ID (org-scoped subscription — team, enterprise, or a
* `pro_*` plan transferred to an org).
*
* This function also performs duplicate subscription validation for organizations:
* - Rejects if an organization already has an active subscription (prevents duplicates)
* - Personal subscriptions (referenceId === userId) skip this check to allow upgrades
* This function also performs duplicate subscription validation for
* organizations:
* - Rejects if an organization already has an active subscription (prevents
* duplicates).
* - Personal subscriptions skip this check to allow upgrades.
*/
export async function authorizeSubscriptionReference(
userId: string,
referenceId: string,
action?: string
): Promise<boolean> {
if (referenceId === userId) {
if (!isOrgScopedSubscription({ referenceId }, userId)) {
return true
}
// Only block duplicate subscriptions during upgrade/checkout, not cancel/restore/list
if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
@@ -32,12 +34,5 @@ export async function authorizeSubscriptionReference(
return false
}
const members = await db
.select()
.from(schema.member)
.where(and(eq(schema.member.userId, userId), eq(schema.member.organizationId, referenceId)))
const member = members[0]
return member?.role === 'owner' || member?.role === 'admin'
return isOrganizationOwnerOrAdmin(userId, referenceId)
}
+130 -140
View File
@@ -1,11 +1,19 @@
import { db } from '@sim/db'
import { member, organization, userStats } from '@sim/db/schema'
import { member, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import { and, eq } from 'drizzle-orm'
import {
getHighestPrioritySubscription,
type HighestPrioritySubscription,
} from '@/lib/billing/core/plan'
import { getPooledOrgCurrentPeriodCost, getUserUsageLimit } from '@/lib/billing/core/usage'
import {
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
} from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { toError } from '@/lib/core/utils/helpers'
@@ -19,6 +27,52 @@ interface UsageData {
isExceeded: boolean
currentUsage: number
limit: number
/**
* Whether the returned values are this user's individual slice or the
* organization's pooled total/cap. When an org pool is the blocker,
* the pooled values are surfaced here so error messages reflect it.
*/
scope: 'user' | 'organization'
/** Present only when `scope === 'organization'`. */
organizationId: string | null
}
/**
* Sum `currentPeriodCost` across all members of an org, then subtract
* daily-refresh credits (with per-user window bounds for mid-cycle
* joiners).
*/
async function computePooledOrgUsage(
organizationId: string,
sub: {
plan: string | null
seats: number | null
periodStart: Date | null
periodEnd: Date | null
}
): Promise<number> {
const { memberIds, currentPeriodCost } = await getPooledOrgCurrentPeriodCost(organizationId)
if (memberIds.length === 0) return 0
let pooled = currentPeriodCost
if (isPaid(sub.plan) && sub.periodStart) {
const planDollars = getPlanTierDollars(sub.plan)
if (planDollars > 0) {
const userBounds = await getOrgMemberRefreshBounds(organizationId, sub.periodStart)
const refresh = await computeDailyRefreshConsumed({
userIds: memberIds,
periodStart: sub.periodStart,
periodEnd: sub.periodEnd ?? null,
planDollars,
seats: sub.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
})
pooled = Math.max(0, pooled - refresh)
}
}
return pooled
}
/**
@@ -30,14 +84,10 @@ export async function checkUsageStatus(
preloadedSubscription?: HighestPrioritySubscription
): Promise<UsageData> {
try {
// If billing is disabled, always return permissive limits
if (!isBillingEnabled) {
// Get actual usage from the database for display purposes
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
const currentUsage =
statsRecords.length > 0
? Number.parseFloat(statsRecords[0].currentPeriodCost?.toString())
: 0
statsRecords.length > 0 ? toNumber(toDecimal(statsRecords[0].currentPeriodCost)) : 0
return {
percentUsed: Math.min((currentUsage / 1000) * 100, 100),
@@ -45,127 +95,68 @@ export async function checkUsageStatus(
isExceeded: false,
currentUsage,
limit: 1000,
scope: 'user',
organizationId: null,
}
}
// Get usage limit from user_stats (per-user cap)
const limit = await getUserUsageLimit(userId, preloadedSubscription)
const sub =
preloadedSubscription !== undefined
? preloadedSubscription
: await getHighestPrioritySubscription(userId)
const limit = await getUserUsageLimit(userId, sub)
logger.info('Using stored usage limit', { userId, limit })
// Get actual usage from the database
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
const subIsOrgScoped = isOrgScopedSubscription(sub, userId)
const scope: 'user' | 'organization' = subIsOrgScoped ? 'organization' : 'user'
const organizationId: string | null = subIsOrgScoped && sub ? sub.referenceId : null
// If no stats record exists, create a default one
if (statsRecords.length === 0) {
logger.info('No usage stats found for user', { userId, limit })
let currentUsage = 0
return {
percentUsed: 0,
isWarning: false,
isExceeded: false,
currentUsage: 0,
limit,
}
}
if (subIsOrgScoped && sub) {
currentUsage = await computePooledOrgUsage(sub.referenceId, sub)
} else {
const statsRecords = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
const rawUsage = Number.parseFloat(
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
)
// Deduct daily refresh credits for individual paid plans only.
// Org plans apply refresh at the pooled level in the org usage check below.
let dailyRefreshDeduction = 0
if (
preloadedSubscription &&
isPaid(preloadedSubscription.plan) &&
!isOrgPlan(preloadedSubscription.plan) &&
preloadedSubscription.periodStart
) {
const planDollars = getPlanTierDollars(preloadedSubscription.plan)
if (planDollars > 0) {
dailyRefreshDeduction = await computeDailyRefreshConsumed({
userIds: [userId],
periodStart: preloadedSubscription.periodStart,
periodEnd: preloadedSubscription.periodEnd ?? null,
planDollars,
})
}
}
const currentUsage = Math.max(0, rawUsage - dailyRefreshDeduction)
const percentUsed = Math.min((currentUsage / limit) * 100, 100)
let isExceeded = currentUsage >= limit
let isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100
try {
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
if (memberships.length > 0) {
for (const m of memberships) {
const orgRows = await db
.select({ id: organization.id, orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, m.organizationId))
.limit(1)
if (orgRows.length) {
const org = orgRows[0]
const teamMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, org.id))
let pooledUsage = 0
if (teamMembers.length > 0) {
const memberIds = teamMembers.map((tm) => tm.userId)
const allMemberStats = await db
.select({ current: userStats.currentPeriodCost, total: userStats.totalCost })
.from(userStats)
.where(inArray(userStats.userId, memberIds))
for (const stats of allMemberStats) {
pooledUsage += Number.parseFloat(
stats.current?.toString() || stats.total.toString()
)
}
}
if (
preloadedSubscription &&
isPaid(preloadedSubscription.plan) &&
preloadedSubscription.periodStart
) {
const planDollars = getPlanTierDollars(preloadedSubscription.plan)
if (planDollars > 0) {
const memberIds = teamMembers.map((tm) => tm.userId)
const orgRefreshDeduction = await computeDailyRefreshConsumed({
userIds: memberIds,
periodStart: preloadedSubscription.periodStart,
periodEnd: preloadedSubscription.periodEnd ?? null,
planDollars,
seats: preloadedSubscription.seats ?? 1,
})
pooledUsage = Math.max(0, pooledUsage - orgRefreshDeduction)
}
}
const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
if (!orgCap || Number.isNaN(orgCap)) {
logger.warn('Organization missing usage limit', { orgId: org.id })
}
if (pooledUsage >= orgCap) {
isExceeded = true
isWarning = false
break
}
}
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,
}
}
} catch (error) {
logger.warn('Error checking organization usage limits', { error, userId })
const rawUsage = toNumber(toDecimal(statsRecords[0].currentPeriodCost))
let refresh = 0
if (sub && isPaid(sub.plan) && sub.periodStart) {
const planDollars = getPlanTierDollars(sub.plan)
if (planDollars > 0) {
refresh = await computeDailyRefreshConsumed({
userIds: [userId],
periodStart: sub.periodStart,
periodEnd: sub.periodEnd ?? null,
planDollars,
})
}
}
currentUsage = Math.max(0, rawUsage - refresh)
}
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 100
const isExceeded = currentUsage >= limit
const isWarning = !isExceeded && percentUsed >= WARNING_THRESHOLD
logger.info('Final usage statistics', {
userId,
currentUsage,
@@ -173,6 +164,8 @@ export async function checkUsageStatus(
percentUsed,
isWarning,
isExceeded,
scope,
organizationId,
})
return {
@@ -181,6 +174,8 @@ export async function checkUsageStatus(
isExceeded,
currentUsage,
limit,
scope,
organizationId,
}
} catch (error) {
logger.error('Error checking usage status', {
@@ -197,9 +192,11 @@ export async function checkUsageStatus(
return {
percentUsed: 100,
isWarning: false,
isExceeded: true, // Block execution when we can't determine status
isExceeded: true,
currentUsage: 0,
limit: 0, // Zero limit forces blocking
limit: 0,
scope: 'user',
organizationId: null,
}
}
}
@@ -210,7 +207,6 @@ export async function checkUsageStatus(
*/
export async function checkAndNotifyUsage(userId: string): Promise<void> {
try {
// Skip usage notifications if billing is disabled
if (!isBillingEnabled) {
return
}
@@ -218,14 +214,12 @@ export async function checkAndNotifyUsage(userId: string): Promise<void> {
const usageData = await checkUsageStatus(userId)
if (usageData.isExceeded) {
// User has exceeded their limit
logger.warn('User has exceeded usage limits', {
userId,
usage: usageData.currentUsage,
limit: usageData.limit,
})
// Dispatch event to show a UI notification
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('usage-exceeded', {
@@ -234,7 +228,6 @@ export async function checkAndNotifyUsage(userId: string): Promise<void> {
)
}
} else if (usageData.isWarning) {
// User is approaching their limit
logger.info('User approaching usage limits', {
userId,
usage: usageData.currentUsage,
@@ -242,7 +235,6 @@ export async function checkAndNotifyUsage(userId: string): Promise<void> {
percent: usageData.percentUsed,
})
// Dispatch event to show a UI notification
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('usage-warning', {
@@ -283,22 +275,17 @@ export async function checkServerSideUsageLimits(
logger.info('Server-side checking usage limits for user', { userId })
// Check user's own blocked status
const stats = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
current: userStats.currentPeriodCost,
total: userStats.totalCost,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
const currentUsage =
stats.length > 0
? Number.parseFloat(stats[0].current?.toString() || stats[0].total.toString())
: 0
const currentUsage = stats.length > 0 ? toNumber(toDecimal(stats[0].current)) : 0
if (stats.length > 0 && stats[0].blocked) {
const message =
@@ -313,14 +300,12 @@ export async function checkServerSideUsageLimits(
}
}
// Check if user is in an org where the owner is blocked
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
for (const m of memberships) {
// Find the owner of this org
const owners = await db
.select({ userId: member.userId })
.from(member)
@@ -354,13 +339,18 @@ export async function checkServerSideUsageLimits(
const usageData = await checkUsageStatus(userId, preloadedSubscription)
const formattedUsage = (usageData.currentUsage ?? 0).toFixed(2)
const formattedLimit = (usageData.limit ?? 0).toFixed(2)
const exceededMessage =
usageData.scope === 'organization'
? `Organization usage limit exceeded: $${formattedUsage} pooled of $${formattedLimit} organization limit. Ask a team admin to raise the organization usage limit to continue.`
: `Usage limit exceeded: $${formattedUsage} used of $${formattedLimit} limit. Please upgrade your plan or raise your usage limit to continue.`
return {
isExceeded: usageData.isExceeded,
currentUsage: usageData.currentUsage,
limit: usageData.limit,
message: usageData.isExceeded
? `Usage limit exceeded: ${usageData.currentUsage?.toFixed(2) || 0}$ used of ${usageData.limit?.toFixed(2) || 0}$ limit. Please upgrade your plan to continue.`
: undefined,
message: usageData.isExceeded ? exceededMessage : undefined,
}
} catch (error) {
logger.error('Error in server-side usage limit check', {
@@ -374,9 +364,9 @@ export async function checkServerSideUsageLimits(
})
return {
isExceeded: true, // Block execution when we can't determine limits
isExceeded: true,
currentUsage: 0,
limit: 0, // Zero limit forces blocking
limit: 0,
message:
error instanceof Error && error.message.includes('No user stats record found')
? 'User account not properly initialized. Please contact support.'
+5
View File
@@ -25,6 +25,9 @@ export interface SubscriptionData {
isPro: boolean
isTeam: boolean
isEnterprise: boolean
/** True when the subscription's `referenceId` is an organization. */
isOrgScoped: boolean
organizationId: string | null
plan: string
status: string | null
seats: number | null
@@ -59,6 +62,8 @@ export interface SubscriptionStore {
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isOrgScoped: boolean
organizationId: string | null
isFree: boolean
plan: string
status: string | null
+9 -8
View File
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { client, useSession, useSubscription } from '@/lib/auth/auth-client'
import { buildPlanName, isOrgPlan } from '@/lib/billing/plan-helpers'
import { buildPlanName, getDisplayPlanName, isPaid } from '@/lib/billing/plan-helpers'
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { organizationKeys } from '@/hooks/queries/organization'
@@ -65,22 +65,23 @@ export function useSubscriptionUpgrade() {
)
if (existingOrg) {
// Check if this org already has an active team subscription
const existingTeamSub = allSubscriptions.find(
const existingOrgSub = allSubscriptions.find(
(sub: any) =>
hasPaidSubscriptionStatus(sub.status) &&
sub.referenceId === existingOrg.id &&
isOrgPlan(sub.plan)
isPaid(sub.plan)
)
if (existingTeamSub) {
logger.warn('Organization already has an active team subscription', {
if (existingOrgSub) {
logger.warn('Organization already has an active subscription', {
userId,
organizationId: existingOrg.id,
existingSubscriptionId: existingTeamSub.id,
existingSubscriptionId: existingOrgSub.id,
plan: existingOrgSub.plan,
})
const existingPlanName = getDisplayPlanName(existingOrgSub.plan)
throw new Error(
'This organization already has an active team subscription. Please manage it from the billing settings.'
`This organization is already on the ${existingPlanName} plan. Manage it from the billing settings.`
)
}
+8 -1
View File
@@ -31,6 +31,8 @@ export function getSubscriptionStatus(
isPro: subscriptionData?.isPro ?? false,
isTeam: subscriptionData?.isTeam ?? false,
isEnterprise: subscriptionData?.isEnterprise ?? false,
isOrgScoped: subscriptionData?.isOrgScoped ?? false,
organizationId: subscriptionData?.organizationId ?? null,
isFree: !(subscriptionData?.isPaid ?? false),
plan: subscriptionData?.plan ?? 'free',
status: subscriptionData?.status ?? null,
@@ -45,7 +47,12 @@ export function getSubscriptionAccessState(
const status = getSubscriptionStatus(subscriptionData)
const billingBlocked = Boolean(subscriptionData?.billingBlocked)
const hasUsablePaidAccess = hasUsableSubscriptionAccess(status.status, billingBlocked)
const hasUsableTeamAccess = hasUsablePaidAccess && (status.isTeam || status.isEnterprise)
// Team-management features (invitations, seats, roles) are available on
// any paid subscription attached to an organization — including `pro_*`
// plans that have been transferred to an org. Plan-name gating would
// miss those.
const hasUsableTeamAccess =
hasUsablePaidAccess && (status.isOrgScoped || status.isTeam || status.isEnterprise)
const hasUsableEnterpriseAccess = hasUsablePaidAccess && status.isEnterprise
const hasUsableMaxAccess =
hasUsablePaidAccess && (getPlanTierCredits(status.plan) >= 25000 || isEnterprise(status.plan))
+276 -299
View File
@@ -1,29 +1,24 @@
import { db } from '@sim/db'
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
import { member, organization, subscription, userStats } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import {
getBillingInterval,
getHighestPrioritySubscription,
type SubscriptionMetadata,
} from '@/lib/billing/core/subscription'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { getOrgUsageLimit, getUserUsageData } from '@/lib/billing/core/usage'
import { getCreditBalance } from '@/lib/billing/credits/balance'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import {
getPlanTierCredits,
getPlanTierDollars,
isEnterprise,
isOrgPlan,
isPaid,
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
} from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getFreeTierLimit,
getPlanPricing,
hasPaidSubscriptionStatus,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal'
@@ -34,7 +29,13 @@ import { createLogger } from '@sim/logger'
const logger = createLogger('Billing')
/**
* Get organization subscription directly by organization ID
* Get the organization's subscription row when its status is one of
* `ENTITLED_SUBSCRIPTION_STATUSES` (includes `past_due`). Use this
* when making billing-side decisions (overage math, limit reads,
* webhooks) where `past_due` still counts as an active paid tenant.
* For product-access gating use `getOrganizationSubscriptionUsable`
* (from `core/subscription.ts`), which excludes `past_due`.
* Returns `null` when there is no entitled sub.
*/
export async function getOrganizationSubscription(organizationId: string) {
try {
@@ -65,45 +66,122 @@ export async function getOrganizationSubscription(organizationId: string) {
*/
/**
* Calculate overage billing for a user
* Returns only the amount that exceeds their subscription base price
* Check if a subscription is scoped to an organization by looking up its
* `referenceId` in the organization table. This is the authoritative
* answer — the plan name alone is unreliable because `pro_*` plans can be
* attached to organizations (and we should treat them as org-scoped).
*
* Use this in server contexts (webhooks, jobs) where we only have the
* subscription row, not a user perspective. If you do have a user id,
* `isOrgScopedSubscription(sub, userId)` is cheaper and equally correct.
*/
export async function calculateUserOverage(userId: string): Promise<{
basePrice: number
actualUsage: number
overageAmount: number
plan: string
} | null> {
try {
// Get user's subscription and usage data
const [subscription, usageData, userRecord] = await Promise.all([
getHighestPrioritySubscription(userId),
getUserUsageData(userId),
db.select().from(user).where(eq(user.id, userId)).limit(1),
])
export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Promise<boolean> {
const rows = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, sub.referenceId))
.limit(1)
return rows.length > 0
}
if (userRecord.length === 0) {
logger.warn('User not found for overage calculation', { userId })
return null
}
/**
* 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): Promise<{
memberIds: string[]
currentPeriodCost: number
currentPeriodCopilotCost: number
lastPeriodCopilotCost: number
}> {
const rows = await db
.select({
userId: member.userId,
currentPeriodCost: userStats.currentPeriodCost,
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
const plan = subscription?.plan || 'free'
const { basePrice } = getPlanPricing(plan)
const actualUsage = usageData.currentUsage
let currentPeriodCost = new Decimal(0)
let currentPeriodCopilotCost = new Decimal(0)
let lastPeriodCopilotCost = new Decimal(0)
const memberIds: string[] = []
// Calculate overage: any usage beyond what they already paid for
const overageAmount = Math.max(0, actualUsage - basePrice)
return {
basePrice,
actualUsage,
overageAmount,
plan,
}
} catch (error) {
logger.error('Failed to calculate user overage', { userId, error })
return null
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.
*/
export async function computeOrgOverageAmount(params: {
plan: string | null
seats: number | null
periodStart: Date | null
periodEnd: Date | null
organizationId: string
pooledCurrentPeriodCost: number
departedMemberUsage: number
memberIds: string[]
}): Promise<{
effectiveUsage: number
baseSubscriptionAmount: number
dailyRefreshDeduction: number
totalOverage: number
}> {
const totalUsage = params.pooledCurrentPeriodCost + params.departedMemberUsage
let dailyRefreshDeduction = 0
const planDollars = getPlanTierDollars(params.plan)
if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) {
const userBounds = await getOrgMemberRefreshBounds(params.organizationId, params.periodStart)
dailyRefreshDeduction = await computeDailyRefreshConsumed({
userIds: params.memberIds,
periodStart: params.periodStart,
periodEnd: params.periodEnd ?? null,
planDollars,
seats: params.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
})
}
const effectiveUsage = Math.max(0, totalUsage - dailyRefreshDeduction)
const { basePrice } = getPlanPricing(params.plan ?? '')
const baseSubscriptionAmount = (params.seats || 1) * basePrice
const totalOverage = Math.max(0, effectiveUsage - baseSubscriptionAmount)
return { effectiveUsage, baseSubscriptionAmount, dailyRefreshDeduction, totalOverage }
}
/**
@@ -129,17 +207,10 @@ export async function calculateSubscriptionOverage(sub: {
let totalOverageDecimal = new Decimal(0)
if (isTeam(sub.plan)) {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
const isOrgScoped = await isSubscriptionOrgScoped(sub)
let totalTeamUsageDecimal = new Decimal(0)
for (const m of members) {
const usage = await getUserUsageData(m.userId)
totalTeamUsageDecimal = totalTeamUsageDecimal.plus(toDecimal(usage.currentUsage))
}
if (isOrgScoped) {
const pooled = await aggregateOrgMemberStats(sub.referenceId)
const orgData = await db
.select({ departedMemberUsage: organization.departedMemberUsage })
@@ -147,82 +218,115 @@ export async function calculateSubscriptionOverage(sub: {
.where(eq(organization.id, sub.referenceId))
.limit(1)
const departedUsageDecimal =
orgData.length > 0 ? toDecimal(orgData[0].departedMemberUsage) : new Decimal(0)
const departedMemberUsage =
orgData.length > 0 ? toNumber(toDecimal(orgData[0].departedMemberUsage)) : 0
const totalUsageWithDepartedDecimal = totalTeamUsageDecimal.plus(departedUsageDecimal)
const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({
plan: sub.plan,
seats: sub.seats ?? null,
periodStart: sub.periodStart ?? null,
periodEnd: sub.periodEnd ?? null,
organizationId: sub.referenceId,
pooledCurrentPeriodCost: pooled.currentPeriodCost,
departedMemberUsage,
memberIds: pooled.memberIds,
})
totalOverageDecimal = toDecimal(totalOverage)
logger.info('Calculated org-scoped overage', {
subscriptionId: sub.id,
plan: sub.plan,
currentMemberUsage: pooled.currentPeriodCost,
departedMemberUsage,
totalUsage: pooled.currentPeriodCost + 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
const joinedOrgMidCycle = snapshotAt !== null || snapshotUsage > 0
const totalProUsageDecimal = joinedOrgMidCycle
? toDecimal(snapshotUsage)
: toDecimal(personalCurrentUsage)
if (joinedOrgMidCycle) {
logger.info('Billing personal Pro only for pre-join usage (user joined org mid-cycle)', {
userId: sub.referenceId,
preJoinUsage: snapshotUsage,
postJoinUsageOnMemberRow: personalCurrentUsage,
snapshotAt: snapshotAt?.toISOString() ?? null,
subscriptionId: sub.id,
})
}
let dailyRefreshDeduction = 0
const planDollars = getPlanTierDollars(sub.plan)
if (planDollars > 0 && sub.periodStart) {
const memberIds = members.map((m) => m.userId)
// 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: memberIds,
userIds: [sub.referenceId],
periodStart: sub.periodStart,
periodEnd: sub.periodEnd ?? null,
periodEnd: refreshCap,
planDollars,
seats: sub.seats ?? 1,
})
}
const effectiveUsageDecimal = Decimal.max(
0,
totalUsageWithDepartedDecimal.minus(toDecimal(dailyRefreshDeduction))
totalProUsageDecimal.minus(toDecimal(dailyRefreshDeduction))
)
const { basePrice } = getPlanPricing(sub.plan ?? '')
const baseSubscriptionAmount = (sub.seats ?? 0) * basePrice
totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(baseSubscriptionAmount))
totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(basePrice))
logger.info('Calculated team overage', {
logger.info('Calculated personal pro overage', {
subscriptionId: sub.id,
currentMemberUsage: toNumber(totalTeamUsageDecimal),
departedMemberUsage: toNumber(departedUsageDecimal),
totalUsage: toNumber(totalUsageWithDepartedDecimal),
baseSubscriptionAmount,
totalOverage: toNumber(totalOverageDecimal),
})
} else if (isPro(sub.plan)) {
// Pro plan: include snapshot if user joined a team
const usage = await getUserUsageData(sub.referenceId)
let totalProUsageDecimal = toDecimal(usage.currentUsage)
// Add any snapshotted Pro usage (from when they joined a team)
const userStatsRows = await db
.select({ proPeriodCostSnapshot: userStats.proPeriodCostSnapshot })
.from(userStats)
.where(eq(userStats.userId, sub.referenceId))
.limit(1)
if (userStatsRows.length > 0 && userStatsRows[0].proPeriodCostSnapshot) {
const snapshotUsageDecimal = toDecimal(userStatsRows[0].proPeriodCostSnapshot)
totalProUsageDecimal = totalProUsageDecimal.plus(snapshotUsageDecimal)
logger.info('Including snapshotted Pro usage in overage calculation', {
userId: sub.referenceId,
currentUsage: usage.currentUsage,
snapshotUsage: toNumber(snapshotUsageDecimal),
totalProUsage: toNumber(totalProUsageDecimal),
})
}
const { basePrice } = getPlanPricing(sub.plan ?? '')
totalOverageDecimal = Decimal.max(0, totalProUsageDecimal.minus(basePrice))
logger.info('Calculated pro overage', {
subscriptionId: sub.id,
totalProUsage: toNumber(totalProUsageDecimal),
joinedOrgMidCycle,
personalCurrentUsage,
snapshot: snapshotUsage,
billedUsage: toNumber(totalProUsageDecimal),
dailyRefreshDeduction,
basePrice,
totalOverage: toNumber(totalOverageDecimal),
})
} else {
// Free plan or unknown plan type
const usage = await getUserUsageData(sub.referenceId)
// Free 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 { basePrice } = getPlanPricing(sub.plan || 'free')
totalOverageDecimal = Decimal.max(0, toDecimal(usage.currentUsage).minus(basePrice))
totalOverageDecimal = Decimal.max(0, toDecimal(personalCurrentUsage).minus(basePrice))
logger.info('Calculated overage for plan', {
subscriptionId: sub.id,
plan: sub.plan || 'free',
usage: usage.currentUsage,
usage: personalCurrentUsage,
basePrice,
totalOverage: toNumber(totalOverageDecimal),
})
@@ -240,10 +344,7 @@ export async function getSimplifiedBillingSummary(
): Promise<{
type: 'individual' | 'organization'
plan: string
basePrice: number
currentUsage: number
overageAmount: number
totalProjected: number
usageLimit: number
percentUsed: number
isWarning: boolean
@@ -251,17 +352,15 @@ export async function getSimplifiedBillingSummary(
daysRemaining: number
creditBalance: number
billingInterval: 'month' | 'year'
tierCredits: number
basePriceCredits: number
currentUsageCredits: number
overageAmountCredits: number
totalProjectedCredits: number
usageLimitCredits: number
// Subscription details
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
/** True when the subscription's `referenceId` is an organization id. */
isOrgScoped: boolean
/** Present when `isOrgScoped` is true. */
organizationId: string | null
status: string | null
seats: number | null
metadata: any
@@ -281,21 +380,6 @@ export async function getSimplifiedBillingSummary(
lastPeriodCopilotCost: number
daysRemaining: number
copilotCost: number
currentCredits: number
limitCredits: number
lastPeriodCostCredits: number
lastPeriodCopilotCostCredits: number
copilotCostCredits: number
}
organizationData?: {
seatCount: number
memberCount: number
totalBasePrice: number
totalCurrentUsage: number
totalOverage: number
totalBasePriceCredits: number
totalCurrentUsageCredits: number
totalOverageCredits: number
}
}> {
try {
@@ -307,13 +391,14 @@ export async function getSimplifiedBillingSummary(
getUserUsageData(userId),
])
// Determine subscription type flags
const plan = subscription?.plan || 'free'
const hasPaidEntitlement = hasPaidSubscriptionStatus(subscription?.status)
const planIsPaid = hasPaidEntitlement && isPaid(plan)
const planIsPro = hasPaidEntitlement && isPro(plan)
const planIsTeam = hasPaidEntitlement && isTeam(plan)
const planIsEnterprise = hasPaidEntitlement && isEnterprise(plan)
const orgScoped = isOrgScopedSubscription(subscription, userId)
const subscriptionOrgId = orgScoped && subscription ? subscription.referenceId : null
if (organizationId) {
// Organization billing summary
@@ -321,96 +406,79 @@ export async function getSimplifiedBillingSummary(
return getDefaultBillingSummary('organization')
}
// Get all organization members
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
// Pool usage/copilot across all members in one query. Must not use
// `getUserUsageData` per-member — it now returns the pool itself
// for org-scoped subs, which would N-times-count.
const pooled = await aggregateOrgMemberStats(organizationId)
const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan)
// Use licensed seats from Stripe as source of truth
const licensedSeats = subscription.seats ?? 0
const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription
const rawCurrentUsage = pooled.currentPeriodCost
const totalCopilotCost = pooled.currentPeriodCopilotCost
const totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost
let totalCurrentUsageDecimal = new Decimal(0)
let totalCopilotCostDecimal = new Decimal(0)
let totalLastPeriodCopilotCostDecimal = new Decimal(0)
// Calculate total team usage across all members
for (const memberInfo of members) {
const memberUsageData = await getUserUsageData(memberInfo.userId)
totalCurrentUsageDecimal = totalCurrentUsageDecimal.plus(
toDecimal(memberUsageData.currentUsage)
)
// Fetch copilot cost for this member
const memberStats = await db
.select({
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
// Deduct daily-refresh credits against this specific org's pool.
// `usageData` is derived from the caller's priority subscription
// and may not match the requested org (multi-org admins, personal
// priority sub, etc.), so it cannot be reused here.
let refreshDeduction = 0
if (isPaid(plan) && subscription.periodStart) {
const planDollars = getPlanTierDollars(plan)
if (planDollars > 0) {
const userBounds = await getOrgMemberRefreshBounds(
organizationId,
subscription.periodStart
)
refreshDeduction = await computeDailyRefreshConsumed({
userIds: pooled.memberIds,
periodStart: subscription.periodStart,
periodEnd: subscription.periodEnd ?? null,
planDollars,
seats: subscription.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
})
.from(userStats)
.where(eq(userStats.userId, memberInfo.userId))
.limit(1)
if (memberStats.length > 0) {
totalCopilotCostDecimal = totalCopilotCostDecimal.plus(
toDecimal(memberStats[0].currentPeriodCopilotCost)
)
totalLastPeriodCopilotCostDecimal = totalLastPeriodCopilotCostDecimal.plus(
toDecimal(memberStats[0].lastPeriodCopilotCost)
)
}
}
const effectiveCurrentUsage = Math.max(0, rawCurrentUsage - refreshDeduction)
const totalCurrentUsage = toNumber(totalCurrentUsageDecimal)
const totalCopilotCost = toNumber(totalCopilotCostDecimal)
const totalLastPeriodCopilotCost = toNumber(totalLastPeriodCopilotCostDecimal)
const { limit: orgUsageLimit } = await getOrgUsageLimit(
organizationId,
plan,
subscription.seats ?? null
)
// Calculate team-level overage: total usage beyond what was already paid to Stripe
const totalOverage = toNumber(Decimal.max(0, totalCurrentUsageDecimal.minus(totalBasePrice)))
// Get user's personal limits for warnings
const percentUsed =
usageData.limit > 0 ? Math.round((usageData.currentUsage / usageData.limit) * 100) : 0
orgUsageLimit > 0 ? Math.round((effectiveCurrentUsage / orgUsageLimit) * 100) : 0
const isExceeded = effectiveCurrentUsage >= orgUsageLimit
const isWarning = !isExceeded && percentUsed >= 80
// Calculate days remaining in billing period
const daysRemaining = usageData.billingPeriodEnd
const daysRemaining = subscription.periodEnd
? Math.max(
0,
Math.ceil((usageData.billingPeriodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
Math.ceil((subscription.periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
)
: 0
const orgCredits = await getCreditBalance(userId)
const orgTotalProjected = totalBasePrice + totalOverage
const orgBillingInterval = getBillingInterval(subscription.metadata as SubscriptionMetadata)
return {
type: 'organization',
plan: subscription.plan,
basePrice: totalBasePrice,
currentUsage: totalCurrentUsage,
overageAmount: totalOverage,
totalProjected: orgTotalProjected,
usageLimit: usageData.limit,
currentUsage: effectiveCurrentUsage,
usageLimit: orgUsageLimit,
percentUsed,
isWarning: percentUsed >= 80 && percentUsed < 100,
isExceeded: usageData.currentUsage >= usageData.limit,
isWarning,
isExceeded,
daysRemaining,
creditBalance: orgCredits.balance,
billingInterval: orgBillingInterval,
tierCredits: getPlanTierCredits(subscription.plan),
basePriceCredits: dollarsToCredits(totalBasePrice),
currentUsageCredits: dollarsToCredits(totalCurrentUsage),
overageAmountCredits: dollarsToCredits(totalOverage),
totalProjectedCredits: dollarsToCredits(orgTotalProjected),
usageLimitCredits: dollarsToCredits(usageData.limit),
// Subscription details
isPaid: planIsPaid,
isPro: planIsPro,
isTeam: planIsTeam,
isEnterprise: planIsEnterprise,
isOrgScoped: true,
organizationId: organizationId,
status: subscription.status || null,
seats: subscription.seats || null,
metadata: subscription.metadata || null,
@@ -419,40 +487,21 @@ export async function getSimplifiedBillingSummary(
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined,
// Usage details
usage: {
current: usageData.currentUsage,
limit: usageData.limit,
current: effectiveCurrentUsage,
limit: orgUsageLimit,
percentUsed,
isWarning: percentUsed >= 80 && percentUsed < 100,
isExceeded: usageData.currentUsage >= usageData.limit,
billingPeriodStart: usageData.billingPeriodStart,
billingPeriodEnd: usageData.billingPeriodEnd,
isWarning,
isExceeded,
billingPeriodStart: subscription.periodStart ?? null,
billingPeriodEnd: subscription.periodEnd ?? null,
lastPeriodCost: usageData.lastPeriodCost,
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
daysRemaining,
copilotCost: totalCopilotCost,
currentCredits: dollarsToCredits(usageData.currentUsage),
limitCredits: dollarsToCredits(usageData.limit),
lastPeriodCostCredits: dollarsToCredits(usageData.lastPeriodCost),
lastPeriodCopilotCostCredits: dollarsToCredits(totalLastPeriodCopilotCost),
copilotCostCredits: dollarsToCredits(totalCopilotCost),
},
organizationData: {
seatCount: licensedSeats,
memberCount: members.length,
totalBasePrice,
totalCurrentUsage,
totalOverage,
totalBasePriceCredits: dollarsToCredits(totalBasePrice),
totalCurrentUsageCredits: dollarsToCredits(totalCurrentUsage),
totalOverageCredits: dollarsToCredits(totalOverage),
},
}
}
// Individual billing summary
const { basePrice } = getPlanPricing(plan)
// Fetch user stats for copilot cost breakdown
const userStatsRows = await db
.select({
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
@@ -468,52 +517,17 @@ export async function getSimplifiedBillingSummary(
const lastPeriodCopilotCost =
userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].lastPeriodCopilotCost)) : 0
// For team and enterprise plans, calculate total team usage instead of individual usage
let currentUsage = usageData.currentUsage
const currentUsage = usageData.currentUsage
let totalCopilotCost = copilotCost
let totalLastPeriodCopilotCost = lastPeriodCopilotCost
if (isOrgPlan(plan) && subscription?.referenceId) {
// Get all team members and sum their usage
const teamMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, subscription.referenceId))
let totalTeamUsageDecimal = new Decimal(0)
let totalTeamCopilotCostDecimal = new Decimal(0)
let totalTeamLastPeriodCopilotCostDecimal = new Decimal(0)
for (const teamMember of teamMembers) {
const memberUsageData = await getUserUsageData(teamMember.userId)
totalTeamUsageDecimal = totalTeamUsageDecimal.plus(toDecimal(memberUsageData.currentUsage))
// Fetch copilot cost for this team member
const memberStats = await db
.select({
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
})
.from(userStats)
.where(eq(userStats.userId, teamMember.userId))
.limit(1)
if (memberStats.length > 0) {
totalTeamCopilotCostDecimal = totalTeamCopilotCostDecimal.plus(
toDecimal(memberStats[0].currentPeriodCopilotCost)
)
totalTeamLastPeriodCopilotCostDecimal = totalTeamLastPeriodCopilotCostDecimal.plus(
toDecimal(memberStats[0].lastPeriodCopilotCost)
)
}
}
currentUsage = toNumber(totalTeamUsageDecimal)
totalCopilotCost = toNumber(totalTeamCopilotCostDecimal)
totalLastPeriodCopilotCost = toNumber(totalTeamLastPeriodCopilotCostDecimal)
if (orgScoped && subscription?.referenceId) {
const pooled = await aggregateOrgMemberStats(subscription.referenceId)
totalCopilotCost = pooled.currentPeriodCopilotCost
totalLastPeriodCopilotCost = pooled.lastPeriodCopilotCost
}
const overageAmount = toNumber(Decimal.max(0, toDecimal(currentUsage).minus(basePrice)))
const percentUsed = usageData.limit > 0 ? (currentUsage / usageData.limit) * 100 : 0
// Calculate days remaining in billing period
const daysRemaining = usageData.billingPeriodEnd
? Math.max(
0,
@@ -522,7 +536,6 @@ export async function getSimplifiedBillingSummary(
: 0
const userCredits = await getCreditBalance(userId)
const individualTotalProjected = basePrice + overageAmount
const individualBillingInterval = getBillingInterval(
subscription?.metadata as SubscriptionMetadata
)
@@ -530,10 +543,7 @@ export async function getSimplifiedBillingSummary(
return {
type: 'individual',
plan,
basePrice,
currentUsage: currentUsage,
overageAmount,
totalProjected: individualTotalProjected,
currentUsage,
usageLimit: usageData.limit,
percentUsed,
isWarning: percentUsed >= 80 && percentUsed < 100,
@@ -541,17 +551,13 @@ export async function getSimplifiedBillingSummary(
daysRemaining,
creditBalance: userCredits.balance,
billingInterval: individualBillingInterval,
tierCredits: getPlanTierCredits(plan),
basePriceCredits: dollarsToCredits(basePrice),
currentUsageCredits: dollarsToCredits(currentUsage),
overageAmountCredits: dollarsToCredits(overageAmount),
totalProjectedCredits: dollarsToCredits(individualTotalProjected),
usageLimitCredits: dollarsToCredits(usageData.limit),
// Subscription details
isPaid: planIsPaid,
isPro: planIsPro,
isTeam: planIsTeam,
isEnterprise: planIsEnterprise,
isOrgScoped: orgScoped,
organizationId: subscriptionOrgId,
status: subscription?.status || null,
seats: subscription?.seats || null,
metadata: subscription?.metadata || null,
@@ -571,11 +577,6 @@ export async function getSimplifiedBillingSummary(
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
daysRemaining,
copilotCost: totalCopilotCost,
currentCredits: dollarsToCredits(currentUsage),
limitCredits: dollarsToCredits(usageData.limit),
lastPeriodCostCredits: dollarsToCredits(usageData.lastPeriodCost),
lastPeriodCopilotCostCredits: dollarsToCredits(totalLastPeriodCopilotCost),
copilotCostCredits: dollarsToCredits(totalCopilotCost),
},
}
} catch (error) {
@@ -592,10 +593,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
return {
type,
plan: 'free',
basePrice: 0,
currentUsage: 0,
overageAmount: 0,
totalProjected: 0,
usageLimit: freeTierLimit,
percentUsed: 0,
isWarning: false,
@@ -603,17 +601,13 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
daysRemaining: 0,
creditBalance: 0,
billingInterval: 'month' as const,
tierCredits: 0,
basePriceCredits: 0,
currentUsageCredits: 0,
overageAmountCredits: 0,
totalProjectedCredits: 0,
usageLimitCredits: dollarsToCredits(freeTierLimit),
// Subscription details
isPaid: false,
isPro: false,
isTeam: false,
isEnterprise: false,
isOrgScoped: false,
organizationId: null,
status: null,
seats: null,
metadata: null,
@@ -632,23 +626,6 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
lastPeriodCopilotCost: 0,
daysRemaining: 0,
copilotCost: 0,
currentCredits: 0,
limitCredits: dollarsToCredits(freeTierLimit),
lastPeriodCostCredits: 0,
lastPeriodCopilotCostCredits: 0,
copilotCostCredits: 0,
},
...(type === 'organization' && {
organizationData: {
seatCount: 0,
memberCount: 0,
totalBasePrice: 0,
totalCurrentUsage: 0,
totalOverage: 0,
totalBasePriceCredits: 0,
totalCurrentUsageCredits: 0,
totalOverageCredits: 0,
},
}),
}
}
+29 -59
View File
@@ -1,44 +1,23 @@
import { db } from '@sim/db'
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
import { member, organization, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers'
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
import {
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
} from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getEffectiveSeats,
getFreeTierLimit,
hasUsableSubscriptionStatus,
} from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
const logger = createLogger('OrganizationBilling')
/**
* Get organization subscription directly by organization ID
* This is for our new pattern where referenceId = organizationId
*/
async function getOrganizationSubscription(organizationId: string) {
try {
const orgSubs = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
return orgSubs.length > 0 ? orgSubs[0] : null
} catch (error) {
logger.error('Error getting organization subscription', { error, organizationId })
return null
}
}
function roundCurrency(value: number): number {
return Math.round(value * 100) / 100
}
@@ -143,51 +122,47 @@ export async function getOrganizationBillingData(
// Calculate aggregated statistics
let totalCurrentUsage = members.reduce((sum, m) => sum + m.currentUsage, 0)
// Deduct daily refresh from pooled usage
if (isPaid(subscription.plan) && subscription.periodStart) {
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars > 0) {
const memberIds = members.map((m) => m.userId)
const userBounds = await getOrgMemberRefreshBounds(
subscription.referenceId,
subscription.periodStart
)
const refreshConsumed = await computeDailyRefreshConsumed({
userIds: memberIds,
periodStart: subscription.periodStart,
periodEnd: subscription.periodEnd ?? null,
planDollars,
seats: subscription.seats ?? 1,
seats: subscription.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
})
totalCurrentUsage = Math.max(0, totalCurrentUsage - refreshConsumed)
}
}
// Get per-seat pricing for the plan
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan)
const licensedSeats = subscription.seats ?? 0
// Stripe subscription quantity; `||` not `??` because 0 seats is
// never valid for a paid sub — fall through to 1.
const licensedSeats = subscription.seats || 1
// For seat count used in UI (invitations, team management):
// Team: seats column (Stripe quantity)
// Enterprise: metadata.seats (allocated seats, not Stripe quantity which is always 1)
// UI seat count — metadata.seats on enterprise (column is always 1).
const effectiveSeats = getEffectiveSeats(subscription)
// Calculate minimum billing amount
let minimumBillingAmount: number
let totalUsageLimit: number
if (isEnterprise(subscription.plan)) {
// Enterprise has fixed pricing set through custom Stripe product
// Their usage limit is configured to match their monthly cost
const configuredLimit = organizationData.orgUsageLimit
? Number.parseFloat(organizationData.orgUsageLimit)
: 0
minimumBillingAmount = configuredLimit // For enterprise, this equals their fixed monthly cost
totalUsageLimit = configuredLimit // Same as their monthly cost
const configuredLimit = toNumber(toDecimal(organizationData.orgUsageLimit))
minimumBillingAmount = configuredLimit
totalUsageLimit = configuredLimit
} else {
// Team plan: Billing is based on licensed seats from Stripe
minimumBillingAmount = licensedSeats * pricePerSeat
// Total usage limit: never below the minimum based on licensed seats
const configuredLimit = organizationData.orgUsageLimit
? Number.parseFloat(organizationData.orgUsageLimit)
? toNumber(toDecimal(organizationData.orgUsageLimit))
: null
totalUsageLimit =
configuredLimit !== null
@@ -197,7 +172,6 @@ export async function getOrganizationBillingData(
const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0
// Billing period comes from the organization's subscription
const billingPeriodStart = subscription.periodStart || null
const billingPeriodEnd = subscription.periodEnd || null
@@ -206,9 +180,9 @@ export async function getOrganizationBillingData(
organizationName: organizationData.name || '',
subscriptionPlan: subscription.plan,
subscriptionStatus: subscription.status || 'inactive',
totalSeats: effectiveSeats, // Uses metadata.seats for enterprise, seats column for team
totalSeats: effectiveSeats,
usedSeats: members.length,
seatsCount: licensedSeats, // Used for billing calculations (Stripe quantity)
seatsCount: licensedSeats,
totalCurrentUsage: roundCurrency(totalCurrentUsage),
totalUsageLimit: roundCurrency(totalUsageLimit),
minimumBillingAmount: roundCurrency(minimumBillingAmount),
@@ -255,7 +229,6 @@ export async function updateOrganizationUsageLimit(
return { success: false, error: 'An active subscription is required to edit usage limits' }
}
// Enterprise plans have fixed usage limits that cannot be changed
if (isEnterprise(subscription.plan)) {
return {
success: false,
@@ -263,18 +236,17 @@ export async function updateOrganizationUsageLimit(
}
}
// Only team plans can update their usage limits
if (!isTeam(subscription.plan)) {
if (!isPaid(subscription.plan)) {
return {
success: false,
error: 'Only team organizations can update usage limits',
error: 'Organization is not on a paid plan',
}
}
const { basePrice } = getPlanPricing(subscription.plan)
const minimumLimit = (subscription.seats ?? 0) * basePrice
const seatCount = subscription.seats || 1
const minimumLimit = seatCount * basePrice
// Validate new limit is not below minimum
if (newLimit < minimumLimit) {
return {
success: false,
@@ -282,8 +254,6 @@ export async function updateOrganizationUsageLimit(
}
}
// Update the organization usage limit
// Convert number to string for decimal column
await db
.update(organization)
.set({
+18 -6
View File
@@ -15,7 +15,17 @@ export type HighestPrioritySubscription = Awaited<ReturnType<typeof getHighestPr
/**
* Get the highest priority paid subscription for a user.
* Priority: Enterprise > Team > Pro > Free
*
* Selection order:
* 1. Plan tier: Enterprise > Team > Pro > Free
* 2. Within the same tier, **org-scoped subs beat personally-scoped subs**.
*
* The tie-break matters because a user can legitimately hold both scopes
* at once — e.g. they accepted an org invite while their own personal Pro
* is still in its `cancelAtPeriodEnd` grace window. In that case the org
* is already paying for their usage, so pooled resources should win over
* the runoff personal sub; otherwise usage, credits, and rate limits would
* leak onto the user's row until the next billing cycle.
*/
export async function getHighestPrioritySubscription(userId: string) {
try {
@@ -59,17 +69,19 @@ export async function getHighestPrioritySubscription(userId: string) {
}
}
const allSubs = [...personalSubs, ...orgSubs]
if (personalSubs.length === 0 && orgSubs.length === 0) return null
if (allSubs.length === 0) return null
// Within each tier, prefer org-scoped over personally-scoped.
const pickAtTier = (predicate: (sub: (typeof personalSubs)[number]) => boolean) =>
orgSubs.find(predicate) ?? personalSubs.find(predicate)
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
const enterpriseSub = pickAtTier(checkEnterprisePlan)
if (enterpriseSub) return enterpriseSub
const teamSub = allSubs.find((s) => checkTeamPlan(s))
const teamSub = pickAtTier(checkTeamPlan)
if (teamSub) return teamSub
const proSub = allSubs.find((s) => checkProPlan(s))
const proSub = pickAtTier(checkProPlan)
if (proSub) return proSub
return null
+61 -40
View File
@@ -61,6 +61,63 @@ export async function writeBillingInterval(
.where(eq(subscription.id, subscriptionId))
}
/**
* Sync the subscription's `plan` column to match Stripe. Closes a gap
* where plan changes (Pro → Team upgrades, tier swaps) updated price,
* seats, and referenceId at Stripe but left the DB plan stale. Returns
* `true` if a write was issued, `false` if no change was needed.
*/
export async function syncSubscriptionPlan(
subscriptionId: string,
currentPlan: string | null,
planFromStripe: string | null
): Promise<boolean> {
if (!planFromStripe) return false
if (currentPlan === planFromStripe) return false
await db
.update(subscription)
.set({ plan: planFromStripe })
.where(eq(subscription.id, subscriptionId))
logger.info('Synced subscription plan name from Stripe', {
subscriptionId,
previousPlan: currentPlan,
newPlan: planFromStripe,
})
return true
}
/**
* Get the organization's subscription row when its status is one of
* `USABLE_SUBSCRIPTION_STATUSES` (product access — stricter than
* `ENTITLED_SUBSCRIPTION_STATUSES` which also includes `past_due`).
* Use this for feature-gating ("can this org use the product right
* now"). Use `getOrganizationSubscription` (from `core/billing.ts`)
* when you need the billing-side entitlement row that includes
* past-due subscriptions. Returns `null` when there is no usable sub.
*/
export async function getOrganizationSubscriptionUsable(organizationId: string) {
try {
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
return orgSub ?? null
} catch (error) {
logger.error('Error getting usable organization subscription', { error, organizationId })
return null
}
}
/**
* Check if a referenceId (user ID or org ID) has a paid subscription row.
* Used for duplicate subscription prevention and transfer safety.
@@ -198,16 +255,7 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boole
return false
}
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, memberRecord.organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
const orgSub = await getOrganizationSubscriptionUsable(memberRecord.organizationId)
const isEnterprise = orgSub && checkEnterprisePlan(orgSub)
@@ -262,16 +310,7 @@ export async function isTeamOrgAdminOrOwner(userId: string): Promise<boolean> {
return false
}
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, memberRecord.organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
const orgSub = await getOrganizationSubscriptionUsable(memberRecord.organizationId)
const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
@@ -311,16 +350,7 @@ export async function isOrganizationOnTeamOrEnterprisePlan(
return false
}
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
const orgSub = await getOrganizationSubscriptionUsable(organizationId)
return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub))
} catch (error) {
@@ -347,16 +377,7 @@ export async function isOrganizationOnEnterprisePlan(organizationId: string): Pr
return false
}
const [orgSub] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
const orgSub = await getOrganizationSubscriptionUsable(organizationId)
return !!orgSub && checkEnterprisePlan(orgSub)
} catch (error) {
+161 -128
View File
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { member, organization, settings, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import {
getEmailSubject,
renderCreditsExhaustedEmail,
@@ -13,15 +13,11 @@ import {
getHighestPrioritySubscription,
type HighestPrioritySubscription,
} from '@/lib/billing/core/plan'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import {
getPlanTierDollars,
isEnterprise,
isFree,
isOrgPlan,
isPaid,
isPro,
} from '@/lib/billing/plan-helpers'
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
} from '@/lib/billing/credits/daily-refresh'
import { getPlanTierDollars, isEnterprise, isFree, isPaid, isPro } from '@/lib/billing/plan-helpers'
import {
canEditUsageLimit,
getFreeTierLimit,
@@ -29,6 +25,7 @@ import {
getPlanPricing,
hasPaidSubscriptionStatus,
hasUsableSubscriptionAccess,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal'
@@ -46,9 +43,44 @@ export interface OrgUsageLimitResult {
}
/**
* Calculates the effective usage limit for a team or enterprise organization.
* - Enterprise: Uses orgUsageLimit directly (fixed pricing)
* - Team: Uses orgUsageLimit but never below seats × basePrice
* Sum `currentPeriodCost` across all members of an organization.
* The single source of truth for pooled-usage reads so every caller
* applies identical null-handling and query shape. Does NOT apply
* daily-refresh deduction — callers layer that on top themselves
* because refresh math needs the caller's `sub` context (plan,
* period, seats, per-user bounds).
*
* Uses `LEFT JOIN` so members whose `userStats` row is missing still
* appear (contributing 0), which keeps `memberIds` complete for
* downstream refresh / bounds computations.
*/
export async function getPooledOrgCurrentPeriodCost(
organizationId: string
): Promise<{ memberIds: string[]; currentPeriodCost: number }> {
const rows = await db
.select({
userId: member.userId,
currentPeriodCost: userStats.currentPeriodCost,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
let pooled = new Decimal(0)
const memberIds: string[] = []
for (const row of rows) {
memberIds.push(row.userId)
pooled = pooled.plus(toDecimal(row.currentPeriodCost))
}
return { memberIds, currentPeriodCost: toNumber(pooled) }
}
/**
* Calculates the effective usage limit for an organization-scoped plan.
* Enterprise uses the configured orgUsageLimit directly; every other
* paid plan uses `basePrice × seats` (Stripe's `price × quantity`) as a
* floor. Returns `{ limit, minimum }` where `limit = max(configured, minimum)`.
*/
export async function getOrgUsageLimit(
organizationId: string,
@@ -76,15 +108,18 @@ export async function getOrgUsageLimit(
}
const { basePrice } = getPlanPricing(plan)
const minimum = (seats ?? 0) * basePrice
// `||` not `??` — 0 is never a valid seat count for a paid sub.
const seatCount = seats || 1
const minimum = seatCount * basePrice
if (configured !== null) {
return { limit: Math.max(configured, minimum), minimum }
}
logger.warn('Team org missing usage limit, using seats × basePrice fallback', {
logger.warn('Org missing usage limit, using plan-driven minimum as fallback', {
orgId: organizationId,
seats,
plan,
seats: seatCount,
minimum,
})
return { limit: minimum, minimum }
@@ -150,11 +185,13 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
}
const stats = userStatsData[0]
const orgScoped = isOrgScopedSubscription(subscription, userId)
let currentUsageDecimal = toDecimal(stats.currentPeriodCost)
// For Pro users, include any snapshotted usage (from when they joined a team)
// This ensures they see their total Pro usage in the UI
if (subscription && isPro(subscription.plan) && subscription.referenceId === userId) {
// For personally-scoped Pro users, include any snapshotted usage from
// 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)
@@ -166,47 +203,60 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
})
}
}
const currentUsage = toNumber(currentUsageDecimal)
let currentUsage = toNumber(currentUsageDecimal)
// Determine usage limit based on plan type
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 (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) {
// Free/Pro: Use individual user limit from userStats
limit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
: getFreeTierLimit()
} else {
// Team/Enterprise: Use organization limit
if (orgScoped && subscription) {
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
limit = orgLimit.limit
const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId)
orgMemberIds = pooled.memberIds
currentUsage = pooled.currentPeriodCost
} else {
limit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
: getFreeTierLimit()
}
// Derive billing period dates from subscription (source of truth).
const billingPeriodStart = subscription?.periodStart ?? null
const billingPeriodEnd = subscription?.periodEnd ?? null
// Compute daily refresh deduction for individual (non-org) paid plans.
// Org plans apply refresh at the pooled level in getEffectiveCurrentPeriodCost.
let dailyRefreshConsumed = 0
if (
subscription &&
isPaid(subscription.plan) &&
!isOrgPlan(subscription.plan) &&
billingPeriodStart
) {
if (subscription && isPaid(subscription.plan) && billingPeriodStart) {
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars > 0) {
dailyRefreshConsumed = await computeDailyRefreshConsumed({
userIds: [userId],
periodStart: billingPeriodStart,
periodEnd: billingPeriodEnd,
planDollars,
})
if (orgScoped) {
if (orgMemberIds.length > 0) {
const userBounds = await getOrgMemberRefreshBounds(
subscription.referenceId,
billingPeriodStart
)
dailyRefreshConsumed = await computeDailyRefreshConsumed({
userIds: orgMemberIds,
periodStart: billingPeriodStart,
periodEnd: billingPeriodEnd,
planDollars,
seats: subscription.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
})
}
} else {
dailyRefreshConsumed = await computeDailyRefreshConsumed({
userIds: [userId],
periodStart: billingPeriodStart,
periodEnd: billingPeriodEnd,
planDollars,
})
}
}
}
@@ -246,21 +296,13 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
}
const stats = userStatsRecord[0]
const orgScoped = isOrgScopedSubscription(subscription, userId)
// Determine limits based on plan type
let currentLimit: number
let minimumLimit: number
let canEdit: boolean
if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) {
// Free/Pro: Use individual limits
currentLimit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
: getFreeTierLimit()
minimumLimit = getPerUserMinimumLimit(subscription)
canEdit = canEditUsageLimit(subscription)
} else {
// Team/Enterprise: Use organization limits
if (orgScoped && subscription) {
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
@@ -269,6 +311,12 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
currentLimit = orgLimit.limit
minimumLimit = orgLimit.minimum
canEdit = false
} else {
currentLimit = stats.currentUsageLimit
? toNumber(toDecimal(stats.currentUsageLimit))
: getFreeTierLimit()
minimumLimit = getPerUserMinimumLimit(subscription)
canEdit = canEditUsageLimit(subscription)
}
return {
@@ -277,6 +325,8 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
minimumLimit,
plan: subscription?.plan || 'free',
updatedAt: stats.usageLimitUpdatedAt,
scope: orgScoped ? 'organization' : 'user',
organizationId: orgScoped && subscription ? subscription.referenceId : null,
}
} catch (error) {
logger.error('Failed to get usage limit info', { userId, error })
@@ -296,26 +346,23 @@ export async function initializeUserUsageLimit(userId: string): Promise<void> {
.limit(1)
if (existingStats.length > 0) {
return // User already has usage stats
return
}
// Check user's subscription to determine initial limit
const subscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterprise = subscription && isOrgPlan(subscription.plan)
const orgScoped = isOrgScopedSubscription(subscription, userId)
// Create initial usage stats
await db.insert(userStats).values({
id: generateId(),
userId,
// Team/enterprise: null (use org limit), Free/Pro: individual limit
currentUsageLimit: isTeamOrEnterprise ? null : getFreeTierLimit().toString(),
currentUsageLimit: orgScoped ? null : getFreeTierLimit().toString(),
usageLimitUpdatedAt: new Date(),
})
logger.info('Initialized user stats', {
userId,
plan: subscription?.plan || 'free',
hasIndividualLimit: !isTeamOrEnterprise,
hasIndividualLimit: !orgScoped,
})
}
@@ -330,11 +377,11 @@ export async function updateUserUsageLimit(
try {
const subscription = await getHighestPrioritySubscription(userId)
// Team/enterprise users don't have individual limits
if (subscription && isOrgPlan(subscription.plan)) {
if (isOrgScopedSubscription(subscription, userId)) {
return {
success: false,
error: 'Team and enterprise members use organization limits',
error:
'This subscription is managed at the organization level. Update the organization usage limit instead.',
}
}
@@ -389,9 +436,9 @@ export async function updateUserUsageLimit(
}
/**
* Get usage limit for a user (used by checkUsageStatus for server-side checks)
* Free/Pro: Individual user limit from userStats
* Team/Enterprise: Organization limit
* Get usage limit for a user (used by checkUsageStatus for server-side
* checks). Org-scoped subs return the organization limit;
* personally-scoped subs return the individual user limit from userStats.
*/
export async function getUserUsageLimit(
userId: string,
@@ -402,46 +449,44 @@ export async function getUserUsageLimit(
? preloadedSubscription
: await getHighestPrioritySubscription(userId)
if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) {
// Free/Pro: Use individual limit from userStats
const userStatsQuery = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, userId))
if (isOrgScopedSubscription(subscription, userId) && subscription) {
const orgExists = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
if (userStatsQuery.length === 0) {
throw new Error(
`No user stats record found for userId: ${userId}. User must be properly initialized before execution.`
)
if (orgExists.length === 0) {
throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`)
}
// Individual limits should never be null for free/pro users
if (!userStatsQuery[0].currentUsageLimit) {
throw new Error(
`Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}. User stats must be properly initialized.`
)
}
return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit))
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
return orgLimit.limit
}
// Team/Enterprise: Verify org exists then use organization limit
const orgExists = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
const userStatsQuery = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (orgExists.length === 0) {
throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`)
if (userStatsQuery.length === 0) {
throw new Error(
`No user stats record found for userId: ${userId}. User must be properly initialized before execution.`
)
}
const orgLimit = await getOrgUsageLimit(
subscription.referenceId,
subscription.plan,
subscription.seats
)
return orgLimit.limit
if (!userStatsQuery[0].currentUsageLimit) {
throw new Error(
`Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}. User stats must be properly initialized.`
)
}
return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit))
}
/**
@@ -486,8 +531,7 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise<v
const currentStats = currentUserStats[0]
// Team/enterprise: Should have null individual limits
if (subscription && isOrgPlan(subscription.plan)) {
if (isOrgScopedSubscription(subscription, userId)) {
if (currentStats.currentUsageLimit !== null) {
await db
.update(userStats)
@@ -497,15 +541,13 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise<v
})
.where(eq(userStats.userId, userId))
logger.info('Cleared individual limit for team/enterprise member', {
logger.info('Cleared individual limit for org-scoped member', {
userId,
plan: subscription.plan,
plan: subscription?.plan,
})
}
return
}
// Free/Pro: Handle individual limits
const defaultLimit = getPerUserMinimumLimit(subscription)
const currentLimit = currentStats.currentUsageLimit
? toNumber(toDecimal(currentStats.currentUsageLimit))
@@ -585,18 +627,23 @@ export async function getTeamUsageLimits(organizationId: string): Promise<
}
/**
* Returns the effective current period usage cost for a user,
* with weekly refresh credits deducted.
* - Free/Pro: user's own currentPeriodCost minus refresh consumed
* - Team/Enterprise: pooled sum of all members' currentPeriodCost minus refresh consumed
* Returns the effective current period usage cost for a user, with daily
* refresh credits deducted. Org-scoped subs return the pooled sum across
* all org members; personally-scoped subs return this user's own cost.
*/
export async function getEffectiveCurrentPeriodCost(userId: string): Promise<number> {
const subscription = await getHighestPrioritySubscription(userId)
const orgScoped = isOrgScopedSubscription(subscription, userId)
let rawCost: number
let refreshUserIds: string[] = [userId]
if (!subscription || isFree(subscription.plan) || isPro(subscription.plan)) {
if (orgScoped && subscription) {
const pooled = await getPooledOrgCurrentPeriodCost(subscription.referenceId)
if (pooled.memberIds.length === 0) return 0
refreshUserIds = pooled.memberIds
rawCost = pooled.currentPeriodCost
} else {
const rows = await db
.select({ current: userStats.currentPeriodCost })
.from(userStats)
@@ -605,26 +652,6 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise<num
if (rows.length === 0) return 0
rawCost = toNumber(toDecimal(rows[0].current))
} else {
const teamMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, subscription.referenceId))
if (teamMembers.length === 0) return 0
const memberIds = teamMembers.map((m) => m.userId)
refreshUserIds = memberIds
const rows = await db
.select({ current: userStats.currentPeriodCost })
.from(userStats)
.where(inArray(userStats.userId, memberIds))
let pooled = new Decimal(0)
for (const r of rows) {
pooled = pooled.plus(toDecimal(r.current))
}
rawCost = toNumber(pooled)
}
if (!subscription || !isPaid(subscription.plan) || !subscription.periodStart) {
@@ -634,12 +661,18 @@ export async function getEffectiveCurrentPeriodCost(userId: string): Promise<num
const planDollars = getPlanTierDollars(subscription.plan)
if (planDollars <= 0) return rawCost
const userBounds =
orgScoped && subscription.periodStart
? await getOrgMemberRefreshBounds(subscription.referenceId, subscription.periodStart)
: {}
const refreshConsumed = await computeDailyRefreshConsumed({
userIds: refreshUserIds,
periodStart: subscription.periodStart,
periodEnd: subscription.periodEnd ?? null,
planDollars,
seats: subscription.seats ?? 1,
seats: subscription.seats || 1,
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
})
return Math.max(0, rawCost - refreshConsumed)
+41 -33
View File
@@ -1,11 +1,14 @@
import { db } from '@sim/db'
import { member, organization, userStats } from '@sim/db/schema'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgPlan, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils'
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
import {
hasUsableSubscriptionAccess,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { Decimal, toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal'
const logger = createLogger('CreditBalance')
@@ -16,31 +19,47 @@ export interface CreditBalanceInfo {
entityId: string
}
/**
* Read credit balance directly from a known entity (user or organization).
* Use this in webhook / admin paths that already know the target entity —
* unlike `getCreditBalance(userId)` it does not route through
* `getHighestPrioritySubscription`, so callers don't need to resolve the
* org owner as a user-id proxy.
*/
export async function getCreditBalanceForEntity(
entityType: 'user' | 'organization',
entityId: string
): Promise<number> {
if (entityType === 'organization') {
const rows = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0
}
const rows = await db
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0
}
export async function getCreditBalance(userId: string): Promise<CreditBalanceInfo> {
const subscription = await getHighestPrioritySubscription(userId)
if (subscription && isOrgPlan(subscription.plan)) {
const orgRows = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, subscription.referenceId))
.limit(1)
if (isOrgScopedSubscription(subscription, userId) && subscription) {
return {
balance: orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].creditBalance)) : 0,
balance: await getCreditBalanceForEntity('organization', subscription.referenceId),
entityType: 'organization',
entityId: subscription.referenceId,
}
}
const userRows = await db
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
return {
balance: userRows.length > 0 ? toNumber(toDecimal(userRows[0].creditBalance)) : 0,
balance: await getCreditBalanceForEntity('user', userId),
entityType: 'user',
entityId: userId,
}
@@ -155,11 +174,11 @@ export async function deductFromCredits(userId: string, cost: number): Promise<D
}
const subscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterprise = isOrgPlan(subscription?.plan)
const orgScoped = isOrgScopedSubscription(subscription, userId)
let creditsUsed: number
if (isTeamOrEnterprise && subscription?.referenceId) {
if (orgScoped && subscription?.referenceId) {
creditsUsed = await atomicDeductOrgCredits(subscription.referenceId, cost)
} else {
creditsUsed = await atomicDeductUserCredits(userId, cost)
@@ -172,7 +191,7 @@ export async function deductFromCredits(userId: string, cost: number): Promise<D
userId,
creditsUsed,
overflow,
entityType: isTeamOrEnterprise ? 'organization' : 'user',
entityType: orgScoped ? 'organization' : 'user',
})
}
@@ -191,14 +210,3 @@ export async function canPurchaseCredits(userId: string): Promise<boolean> {
// Enterprise users must contact support to purchase credits
return isPro(subscription.plan) || isTeam(subscription.plan)
}
export async function isOrgAdmin(userId: string, organizationId: string): Promise<boolean> {
const memberRows = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userId)))
.limit(1)
if (memberRows.length === 0) return false
return memberRows[0].role === 'owner' || memberRows[0].role === 'admin'
}
+72 -10
View File
@@ -12,15 +12,26 @@
*/
import { db } from '@sim/db'
import { usageLog } from '@sim/db/schema'
import { member, usageLog, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, gte, inArray, lt, sql, sum } from 'drizzle-orm'
import { and, eq, gte, inArray, lt, or, sql, sum } from 'drizzle-orm'
import { DAILY_REFRESH_RATE } from '@/lib/billing/constants'
const logger = createLogger('DailyRefresh')
const MS_PER_DAY = 86_400_000
/**
* Optional per-user date window. `usageLog` rows outside
* `[userStart, userEnd)` are excluded from that user's contribution.
* Used to slice refresh around a mid-cycle org join so pre-join and
* post-join refresh are billed by the right subscription.
*/
export interface PerUserBounds {
userStart?: Date | null
userEnd?: Date | null
}
/**
* Compute the total daily refresh credits consumed in the current billing period
* using a single aggregating SQL query grouped by day offset.
@@ -36,8 +47,9 @@ export async function computeDailyRefreshConsumed(params: {
periodEnd?: Date | null
planDollars: number
seats?: number
userBounds?: Record<string, PerUserBounds>
}): Promise<number> {
const { userIds, periodStart, periodEnd, planDollars, seats = 1 } = params
const { userIds, periodStart, periodEnd, planDollars, seats = 1, userBounds } = params
if (planDollars <= 0 || userIds.length === 0) return 0
@@ -51,6 +63,39 @@ export async function computeDailyRefreshConsumed(params: {
const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY)
if (dayCount <= 0) return 0
const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds
const boundedClauses = userBounds
? Object.entries(userBounds).flatMap(([userId, bounds]) => {
if (!userIds.includes(userId)) return []
const effectiveStart =
bounds.userStart && bounds.userStart > periodStart ? bounds.userStart : periodStart
const effectiveEnd = bounds.userEnd && bounds.userEnd < cap ? bounds.userEnd : cap
if (effectiveEnd <= effectiveStart) return []
return [
and(
eq(usageLog.userId, userId),
gte(usageLog.createdAt, effectiveStart),
lt(usageLog.createdAt, effectiveEnd)
),
]
})
: []
const rowFilters =
unboundedUsers.length > 0
? [
and(
inArray(usageLog.userId, unboundedUsers),
gte(usageLog.createdAt, periodStart),
lt(usageLog.createdAt, cap)
),
...boundedClauses,
]
: boundedClauses
if (rowFilters.length === 0) return 0
const rows = await db
.select({
dayIndex:
@@ -60,13 +105,7 @@ export async function computeDailyRefreshConsumed(params: {
dayTotal: sum(usageLog.cost).as('day_total'),
})
.from(usageLog)
.where(
and(
inArray(usageLog.userId, userIds),
gte(usageLog.createdAt, periodStart),
lt(usageLog.createdAt, cap)
)
)
.where(rowFilters.length === 1 ? rowFilters[0] : or(...rowFilters))
.groupBy(sql`day_index`)
let totalConsumed = 0
@@ -81,6 +120,7 @@ export async function computeDailyRefreshConsumed(params: {
days: dayCount,
dailyRefreshDollars,
totalConsumed,
hasUserBounds: Boolean(userBounds),
})
return totalConsumed
@@ -92,3 +132,25 @@ export async function computeDailyRefreshConsumed(params: {
export function getDailyRefreshDollars(planDollars: number): number {
return planDollars * DAILY_REFRESH_RATE
}
export async function getOrgMemberRefreshBounds(
organizationId: string,
periodStart: Date
): Promise<Record<string, { userStart: Date }>> {
const rows = await db
.select({
userId: member.userId,
snapshotAt: userStats.proPeriodCostSnapshotAt,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
const bounds: Record<string, { userStart: Date }> = {}
for (const row of rows) {
if (row.snapshotAt && row.snapshotAt > periodStart) {
bounds[row.userId] = { userStart: row.snapshotAt }
}
}
return bounds
}
+35 -35
View File
@@ -2,12 +2,15 @@ import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { canPurchaseCredits, isOrgAdmin } from '@/lib/billing/credits/balance'
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
import { canPurchaseCredits } from '@/lib/billing/credits/balance'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getCustomerId, resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
const logger = createLogger('CreditPurchase')
@@ -24,8 +27,10 @@ export async function setUsageLimitForCredits(
): Promise<void> {
try {
const { basePrice } = getPlanPricing(plan)
const seatCount = seats || 1
const planBase =
entityType === 'organization' ? Number(basePrice) * (seats || 1) : Number(basePrice)
entityType === 'organization' ? Number(basePrice) * seatCount : Number(basePrice)
const creditBalanceNum = Number(creditBalance)
const newLimit = planBase + creditBalanceNum
@@ -36,8 +41,7 @@ export async function setUsageLimitForCredits(
.where(eq(organization.id, entityId))
.limit(1)
const currentLimit =
orgRows.length > 0 ? Number.parseFloat(orgRows[0].orgUsageLimit || '0') : 0
const currentLimit = orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].orgUsageLimit)) : 0
if (newLimit > currentLimit) {
await db
@@ -63,7 +67,7 @@ export async function setUsageLimitForCredits(
.limit(1)
const currentLimit =
userStatsRows.length > 0 ? Number.parseFloat(userStatsRows[0].currentUsageLimit || '0') : 0
userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentUsageLimit)) : 0
if (newLimit > currentLimit) {
await db
@@ -97,12 +101,6 @@ export interface PurchaseResult {
error?: string
}
function getPaymentMethodId(
pm: string | Stripe.PaymentMethod | null | undefined
): string | undefined {
return typeof pm === 'string' ? pm : pm?.id
}
export async function purchaseCredits(params: PurchaseCreditsParams): Promise<PurchaseResult> {
const { userId, amountDollars, requestId } = params
@@ -128,8 +126,10 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise<Pu
let entityType: 'user' | 'organization' = 'user'
let entityId = userId
if (isTeam(subscription.plan)) {
const isAdmin = await isOrgAdmin(userId, subscription.referenceId)
// Org-scoped subs route credit purchases to the organization and must be authorized
// by an org owner/admin. We've already rejected enterprise above.
if (isOrgScopedSubscription(subscription, userId)) {
const isAdmin = await isOrganizationOwnerOrAdmin(userId, subscription.referenceId)
if (!isAdmin) {
return { success: false, error: 'Only organization owners and admins can purchase credits' }
}
@@ -140,23 +140,18 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise<Pu
try {
const stripe = requireStripeClient()
// Get customer ID and payment method from subscription
const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId)
const customerId =
typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id
// Get default payment method
let defaultPaymentMethod: string | undefined
const subPm = getPaymentMethodId(stripeSub.default_payment_method)
if (subPm) {
defaultPaymentMethod = subPm
} else {
const customer = await stripe.customers.retrieve(customerId)
if (customer && !('deleted' in customer)) {
defaultPaymentMethod = getPaymentMethodId(customer.invoice_settings?.default_payment_method)
}
const customerId = getCustomerId(stripeSub.customer)
if (!customerId) {
return { success: false, error: 'Subscription missing customer' }
}
const { paymentMethodId: defaultPaymentMethod } = await resolveDefaultPaymentMethod(
stripe,
subscription.stripeSubscriptionId,
customerId
)
if (!defaultPaymentMethod) {
return {
success: false,
@@ -198,7 +193,7 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise<Pu
description: `Prepaid credits ($${amountDollars})`,
metadata: creditMetadata,
},
{ idempotencyKey }
{ idempotencyKey: `${idempotencyKey}-item` }
)
// Finalize and pay
@@ -206,13 +201,18 @@ export async function purchaseCredits(params: PurchaseCreditsParams): Promise<Pu
return { success: false, error: 'Failed to create invoice' }
}
const finalized = await stripe.invoices.finalizeInvoice(invoice.id)
const finalized = await stripe.invoices.finalizeInvoice(
invoice.id,
{},
{ idempotencyKey: `${idempotencyKey}-finalize` }
)
if (finalized.status === 'open' && finalized.id) {
await stripe.invoices.pay(finalized.id, {
payment_method: defaultPaymentMethod,
})
// Credits are added via webhook (handleInvoicePaymentSucceeded) after payment confirmation
await stripe.invoices.pay(
finalized.id,
{ payment_method: defaultPaymentMethod },
{ idempotencyKey: `${idempotencyKey}-pay` }
)
}
logger.info('Credit purchase invoice created and paid', {
+70 -7
View File
@@ -5,13 +5,15 @@ import {
session,
subscription as subscriptionTable,
user,
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { hasPaidSubscription } from '@/lib/billing'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isTeam } from '@/lib/billing/plan-helpers'
import { isEnterprise, isPaid, isTeam } from '@/lib/billing/plan-helpers'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { generateId } from '@/lib/core/utils/uuid'
const logger = createLogger('BillingOrganization')
@@ -258,10 +260,12 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
// Organization subscription - set org usage limit and sync member limits
const organizationId = subscription.referenceId
// Set orgUsageLimit for team plans (enterprise is set via webhook with custom pricing)
if (isTeam(subscription.plan)) {
// Set orgUsageLimit for any paid non-enterprise plan attached to
// the org. Enterprise is set via webhook with custom pricing.
// Min = basePrice × seats, mirroring Stripe's `price × quantity`.
if (isPaid(subscription.plan) && !isEnterprise(subscription.plan)) {
const { basePrice } = getPlanPricing(subscription.plan)
const seats = subscription.seats ?? 1
const seats = subscription.seats || 1
const orgLimit = seats * basePrice
// Only set if not already set or if updating to a higher value based on seats
@@ -273,7 +277,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
const currentLimit =
orgData.length > 0 && orgData[0].orgUsageLimit
? Number.parseFloat(orgData[0].orgUsageLimit)
? toNumber(toDecimal(orgData[0].orgUsageLimit))
: 0
// Update if no limit set, or if new seat-based minimum is higher
@@ -286,8 +290,9 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
})
.where(eq(organization.id, organizationId))
logger.info('Set organization usage limit for team plan', {
logger.info('Set organization usage limit', {
organizationId,
plan: subscription.plan,
seats,
basePrice,
orgLimit,
@@ -322,6 +327,64 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
subscriptionId: subscription.id,
plan: subscription.plan,
})
// Bulk version of the per-member transfer in invitation-accept:
// catches members whose personal bytes never made it into the
// org pool (e.g. org upgraded free → paid after they joined).
// `.for('update')` row-locks so concurrent increment/decrement
// calls cannot slip between the snapshot SELECT and the
// zeroing UPDATE and get silently dropped. Idempotent — zeroed
// rows are filtered out.
if (isPaid(subscription.plan)) {
try {
const memberIds = members.map((m) => m.userId)
await db.transaction(async (tx) => {
const personalStorageRows = await tx
.select({
userId: userStats.userId,
bytes: userStats.storageUsedBytes,
})
.from(userStats)
.where(inArray(userStats.userId, memberIds))
.for('update')
const toTransfer = personalStorageRows.filter((r) => (r.bytes ?? 0) > 0)
const totalBytes = toTransfer.reduce((acc, r) => acc + (r.bytes ?? 0), 0)
if (totalBytes === 0) return
await tx
.update(organization)
.set({
storageUsedBytes: sql`${organization.storageUsedBytes} + ${totalBytes}`,
})
.where(eq(organization.id, organizationId))
await tx
.update(userStats)
.set({ storageUsedBytes: 0 })
.where(
inArray(
userStats.userId,
toTransfer.map((r) => r.userId)
)
)
logger.info('Transferred personal storage bytes to org pool during sync', {
organizationId,
subscriptionId: subscription.id,
memberCount: toTransfer.length,
totalBytes,
})
})
} catch (storageError) {
logger.error('Failed to transfer personal storage to org pool', {
organizationId,
subscriptionId: subscription.id,
error: storageError,
})
}
}
}
}
} catch (error) {
@@ -16,10 +16,12 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { generateId } from '@/lib/core/utils/uuid'
const logger = createLogger('OrganizationMembership')
@@ -137,31 +139,28 @@ export async function restoreUserProSubscription(userId: string): Promise<Restor
result.subscriptionId = personalPro.id
try {
const stripe = requireStripeClient()
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
cancel_at_period_end: false,
})
} catch (stripeError) {
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
userId,
stripeSubscriptionId: personalPro.stripeSubscriptionId,
error: stripeError,
})
}
await db.transaction(async (tx) => {
await tx
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: false })
.where(eq(subscriptionTable.id, personalPro.id))
try {
await db
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: false })
.where(eq(subscriptionTable.id, personalPro.id))
if (personalPro.stripeSubscriptionId) {
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
stripeSubscriptionId: personalPro.stripeSubscriptionId,
subscriptionId: personalPro.id,
reason: 'member-left-paid-org',
})
}
})
result.restored = true
logger.info('Restored personal Pro subscription', {
logger.info('Restored personal Pro subscription (DB committed, Stripe queued)', {
userId,
subscriptionId: personalPro.id,
})
} catch (dbError) {
logger.error('DB update failed when restoring personal Pro', {
logger.error('Failed to restore personal Pro subscription', {
userId,
subscriptionId: personalPro.id,
error: dbError,
@@ -179,12 +178,10 @@ export async function restoreUserProSubscription(userId: string): Promise<Restor
.limit(1)
if (stats) {
const currentUsage = stats.currentPeriodCost || '0'
const snapshotUsage = stats.proPeriodCostSnapshot || '0'
const snapshotNum = Number.parseFloat(snapshotUsage)
const currentNum = toNumber(toDecimal(stats.currentPeriodCost))
const snapshotNum = toNumber(toDecimal(stats.proPeriodCostSnapshot))
if (snapshotNum > 0) {
const currentNum = Number.parseFloat(currentUsage)
const restoredUsage = (currentNum + snapshotNum).toString()
await db
@@ -192,6 +189,7 @@ export async function restoreUserProSubscription(userId: string): Promise<Restor
.set({
currentPeriodCost: restoredUsage,
proPeriodCostSnapshot: '0',
proPeriodCostSnapshotAt: null,
})
.where(eq(userStats.userId, userId))
@@ -199,8 +197,8 @@ export async function restoreUserProSubscription(userId: string): Promise<Restor
logger.info('Restored Pro usage snapshot', {
userId,
previousUsage: currentUsage,
snapshotUsage,
previousUsage: currentNum,
snapshotUsage: snapshotNum,
restoredUsage,
})
}
@@ -237,12 +235,12 @@ export interface AddMemberResult {
error?: string
billingActions: {
proUsageSnapshotted: boolean
/**
* True when this function marked the user's personal Pro for
* cancellation at period end AND enqueued the Stripe sync via
* the outbox. Callers should NOT make a Stripe call themselves.
*/
proCancelledAtPeriodEnd: boolean
/** If Pro was cancelled, contains info for Stripe update (caller can optionally call Stripe) */
proSubscriptionToCancel?: {
subscriptionId: string
stripeSubscriptionId: string | null
}
}
}
@@ -414,7 +412,7 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
)
.limit(1)
const orgIsPaid = orgSub && isOrgPlan(orgSub.plan)
const orgIsPaid = orgSub && isPaid(orgSub.plan)
let memberId = ''
@@ -459,6 +457,7 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
.update(userStats)
.set({
proPeriodCostSnapshot: currentProUsage,
proPeriodCostSnapshotAt: new Date(),
currentPeriodCost: '0',
currentPeriodCopilotCost: '0',
})
@@ -473,26 +472,60 @@ export async function addUserToOrganization(params: AddMemberParams): Promise<Ad
})
}
// Mark Pro for cancellation at period end
// Mark Pro for cancellation at period end AND enqueue the
// Stripe sync atomically with this transaction. Caller must
// not make a Stripe call — the outbox worker handles it.
if (!personalPro.cancelAtPeriodEnd) {
await tx
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscriptionTable.id, personalPro.id))
billingActions.proCancelledAtPeriodEnd = true
billingActions.proSubscriptionToCancel = {
subscriptionId: personalPro.id,
stripeSubscriptionId: personalPro.stripeSubscriptionId,
if (personalPro.stripeSubscriptionId) {
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
stripeSubscriptionId: personalPro.stripeSubscriptionId,
subscriptionId: personalPro.id,
reason: 'admin-added-to-paid-org',
})
}
logger.info('Marked personal Pro for cancellation at period end', {
billingActions.proCancelledAtPeriodEnd = true
logger.info('Marked personal Pro for cancellation at period end (Stripe queued)', {
userId,
subscriptionId: personalPro.id,
organizationId,
})
}
}
const storageRows = await tx
.select({ storageUsedBytes: userStats.storageUsedBytes })
.from(userStats)
.where(eq(userStats.userId, userId))
.for('update')
.limit(1)
const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0
if (bytesToTransfer > 0) {
await tx
.update(organization)
.set({
storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`,
})
.where(eq(organization.id, organizationId))
await tx
.update(userStats)
.set({ storageUsedBytes: 0 })
.where(eq(userStats.userId, userId))
logger.info('Transferred personal storage bytes to org pool on admin add', {
userId,
organizationId,
bytes: bytesToTransfer,
})
}
}
})
@@ -565,7 +598,7 @@ export async function removeUserFromOrganization(
.limit(1)
if (departingUserStats?.currentPeriodCost) {
const usage = Number.parseFloat(departingUserStats.currentPeriodCost)
const usage = toNumber(toDecimal(departingUserStats.currentPeriodCost))
if (usage > 0) {
await db
.update(organization)
@@ -627,7 +660,8 @@ export async function removeUserFromOrganization(
)
)
hasAnyPaidTeam = orgPaidSubs.some((s) => isOrgPlan(s.plan))
// Still covered by a paid org sub → don't restore personal Pro.
hasAnyPaidTeam = orgPaidSubs.some((s) => isPaid(s.plan))
}
if (!hasAnyPaidTeam) {
+7
View File
@@ -42,6 +42,13 @@ export function isPaid(plan: string | null | undefined): boolean {
return isPro(plan) || isTeam(plan) || isEnterprise(plan)
}
/**
* True when the plan **name** is a team/enterprise plan. This is a
* plan-name check, NOT a scope check — a `pro_*` plan attached to an
* organization is org-scoped at the billing level even though this
* returns `false` for it. For scope decisions use
* `isOrgScopedSubscription` (sync) or `isSubscriptionOrgScoped` (async).
*/
export function isOrgPlan(plan: string | null | undefined): boolean {
return isTeam(plan) || isEnterprise(plan)
}
+16 -19
View File
@@ -13,7 +13,8 @@ import {
import { organization, subscription, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getPlanTypeForLimits, isEnterprise, isFree, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getPlanTypeForLimits, isEnterprise, isFree } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { getEnv } from '@/lib/core/config/env'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
@@ -78,7 +79,6 @@ export function getStorageLimitForPlan(plan: string, metadata?: any): number {
*/
export async function getUserStorageLimit(userId: string): Promise<number> {
try {
// Check if user is in a team/enterprise org
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const sub = await getHighestPrioritySubscription(userId)
@@ -88,18 +88,9 @@ export async function getUserStorageLimit(userId: string): Promise<number> {
return limits.free
}
if (!isOrgPlan(sub.plan)) {
const effectivePlan = getPlanTypeForLimits(sub.plan)
const limitByPlan: Record<'free' | 'pro' | 'team', number> = {
free: limits.free,
pro: limits.pro,
team: limits.team,
}
return limitByPlan[effectivePlan as 'free' | 'pro' | 'team'] ?? limits.free
}
if (isOrgPlan(sub.plan)) {
// Get organization storage limit
// Org-scoped subs use pooled org-level storage. Custom limits come from the
// subscription metadata; otherwise use the team/enterprise default.
if (isOrgScopedSubscription(sub, userId)) {
const orgRecord = await db
.select({ metadata: subscription.metadata })
.from(subscription)
@@ -113,11 +104,17 @@ export async function getUserStorageLimit(userId: string): Promise<number> {
}
}
// Default for team/enterprise
return isEnterprise(sub.plan) ? limits.enterpriseDefault : limits.team
}
return limits.free
// Personally-scoped plans use the per-plan default storage cap.
const effectivePlan = getPlanTypeForLimits(sub.plan)
const limitByPlan: Record<'free' | 'pro' | 'team', number> = {
free: limits.free,
pro: limits.pro,
team: limits.team,
}
return limitByPlan[effectivePlan as 'free' | 'pro' | 'team'] ?? limits.free
} catch (error) {
logger.error('Error getting user storage limit:', error)
return getStorageLimits().free
@@ -130,11 +127,12 @@ export async function getUserStorageLimit(userId: string): Promise<number> {
*/
export async function getUserStorageUsage(userId: string): Promise<number> {
try {
// Check if user is in a team/enterprise org
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const sub = await getHighestPrioritySubscription(userId)
if (sub && isOrgPlan(sub.plan)) {
// Org-scoped subs share pooled `organization.storageUsedBytes`;
// personal plans use `userStats`.
if (isOrgScopedSubscription(sub, userId) && sub) {
const orgRecord = await db
.select({ storageUsedBytes: organization.storageUsedBytes })
.from(organization)
@@ -144,7 +142,6 @@ export async function getUserStorageUsage(userId: string): Promise<number> {
return orgRecord.length > 0 ? orgRecord[0].storageUsedBytes || 0 : 0
}
// Free/Pro: Use user stats
const stats = await db
.select({ storageUsedBytes: userStats.storageUsedBytes })
.from(userStats)
+4 -7
View File
@@ -8,7 +8,7 @@ import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('StorageTracking')
@@ -24,11 +24,11 @@ export async function incrementStorageUsage(userId: string, bytes: number): Prom
}
try {
// Check if user is in a team/enterprise org
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const sub = await getHighestPrioritySubscription(userId)
if (sub && isOrgPlan(sub.plan)) {
// Org-scoped subs pool at the org level; personal plans per-user.
if (isOrgScopedSubscription(sub, userId) && sub) {
await db
.update(organization)
.set({
@@ -38,7 +38,6 @@ export async function incrementStorageUsage(userId: string, bytes: number): Prom
logger.info(`Incremented org storage: ${bytes} bytes for org ${sub.referenceId}`)
} else {
// Update user stats storage
await db
.update(userStats)
.set({
@@ -65,11 +64,10 @@ export async function decrementStorageUsage(userId: string, bytes: number): Prom
}
try {
// Check if user is in a team/enterprise org
const { getHighestPrioritySubscription } = await import('@/lib/billing/core/subscription')
const sub = await getHighestPrioritySubscription(userId)
if (sub && isOrgPlan(sub.plan)) {
if (isOrgScopedSubscription(sub, userId) && sub) {
await db
.update(organization)
.set({
@@ -79,7 +77,6 @@ export async function decrementStorageUsage(userId: string, bytes: number): Prom
logger.info(`Decremented org storage: ${bytes} bytes for org ${sub.referenceId}`)
} else {
// Update user stats storage
await db
.update(userStats)
.set({
@@ -0,0 +1,74 @@
import { createLogger } from '@sim/logger'
import type Stripe from 'stripe'
const logger = createLogger('StripePaymentMethod')
/**
* Extract the payment-method id from any of the shapes Stripe returns
* for a `default_payment_method` field (id string, full object, null,
* or undefined).
*/
function getPaymentMethodId(
pm: string | Stripe.PaymentMethod | null | undefined
): string | undefined {
return typeof pm === 'string' ? pm : pm?.id
}
/**
* Extract the customer id from any of the shapes Stripe returns for a
* `customer` field (id string, full `Customer`, or `DeletedCustomer`).
*/
export function getCustomerId(
customer: string | Stripe.Customer | Stripe.DeletedCustomer | null | undefined
): string | undefined {
if (!customer) return undefined
return typeof customer === 'string' ? customer : customer.id
}
/**
* Resolve a subscription's default payment method with fallback to the
* customer's invoice-settings PM. Used for ad-hoc invoices that are
* not directly linked to the subscription (overage, credits, threshold
* billing) so Stripe can auto-collect on finalize.
*
* Returns both the resolved PM id and the subscription's collection
* method so callers can pass it through to `invoices.create` without a
* second subscription retrieve. On any Stripe error the returned
* `collectionMethod` is `null` — callers should treat that as
* "unknown" and handle accordingly rather than assuming a default.
*/
export async function resolveDefaultPaymentMethod(
stripe: Stripe,
stripeSubscriptionId: string,
customerId: string
): Promise<{
paymentMethodId: string | undefined
collectionMethod: 'charge_automatically' | 'send_invoice' | null
}> {
let collectionMethod: 'charge_automatically' | 'send_invoice' | null = null
let paymentMethodId: string | undefined
try {
const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId)
collectionMethod =
sub.collection_method === 'send_invoice' ? 'send_invoice' : 'charge_automatically'
paymentMethodId = getPaymentMethodId(sub.default_payment_method)
if (!paymentMethodId && collectionMethod === 'charge_automatically') {
const customer = await stripe.customers.retrieve(customerId)
if (customer && !('deleted' in customer)) {
paymentMethodId = getPaymentMethodId(
(customer as Stripe.Customer).invoice_settings?.default_payment_method
)
}
}
} catch (error) {
logger.warn('Failed to resolve default payment method', {
stripeSubscriptionId,
customerId,
error: error instanceof Error ? error.message : error,
})
}
return { paymentMethodId, collectionMethod }
}
+24 -7
View File
@@ -93,7 +93,9 @@ export function getEffectiveSeats(subscription: any): number {
return 0
}
if (isTeam(subscription.plan)) {
// Mirrors the Stripe subscription's `quantity`. For personal Pro this
// is null in practice, so `?? 0` returns 0.
if (isTeam(subscription.plan) || isPro(subscription.plan)) {
return subscription.seats ?? 0
}
@@ -109,9 +111,27 @@ export function checkTeamPlan(subscription: any): boolean {
}
/**
* Get the minimum usage limit for an individual user (used for validation)
* Only applicable for plans with individual limits (Free/Pro)
* Team and Enterprise plans use organization-level limits instead
* True when the subscription's `referenceId` is an org (i.e. not the
* caller's own `userId`). Prefer this over plan-name checks for scope
* decisions — a `pro_*` sub attached to an org is org-scoped even though
* `isTeam` / `isOrgPlan` return false.
*/
export function isOrgScopedSubscription(
subscription: { referenceId?: string | null } | null | undefined,
userId: string
): boolean {
if (!subscription?.referenceId) return false
return subscription.referenceId !== userId
}
/**
* Get the minimum usage limit for an individual user (used for validation).
*
* Callers should only invoke this for **personally-scoped** subscriptions —
* any org-scoped subscription (team, enterprise, or `pro_*` attached to an
* organization) uses the organization-level limit instead. Callers are
* responsible for gating with `isOrgScopedSubscription` before calling.
*
* @param subscription The subscription object
* @returns The per-user minimum limit in dollars
*/
@@ -127,9 +147,6 @@ export function getPerUserMinimumLimit(subscription: any): number {
}
if (isOrgPlan(subscription.plan)) {
// Team and Enterprise don't have individual limits - they use organization limits
// This function should not be called for these plans
// Returning 0 to indicate no individual minimum
return 0
}
+118 -229
View File
@@ -1,114 +1,28 @@
import { db } from '@sim/db'
import { member, organization, subscription, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, sql } from 'drizzle-orm'
import type Stripe from 'stripe'
import { eq, inArray, sql } from 'drizzle-orm'
import { DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { calculateSubscriptionOverage, getPlanPricing } from '@/lib/billing/core/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh'
import { calculateSubscriptionOverage, computeOrgOverageAmount } from '@/lib/billing/core/billing'
import {
getPlanTierDollars,
isEnterprise,
isFree,
isPaid,
isTeam,
} from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
getHighestPrioritySubscription,
getOrganizationSubscriptionUsable,
} from '@/lib/billing/core/subscription'
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
import {
hasUsableSubscriptionAccess,
USABLE_SUBSCRIPTION_STATUSES,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { env } from '@/lib/core/config/env'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
const logger = createLogger('ThresholdBilling')
const OVERAGE_THRESHOLD = env.OVERAGE_THRESHOLD_DOLLARS || DEFAULT_OVERAGE_THRESHOLD
function parseDecimal(value: string | number | null | undefined): number {
if (value === null || value === undefined) return 0
return Number.parseFloat(value.toString())
}
async function createAndFinalizeOverageInvoice(
stripe: ReturnType<typeof requireStripeClient>,
params: {
customerId: string
stripeSubscriptionId: string
amountCents: number
description: string
itemDescription: string
metadata: Record<string, string>
idempotencyKey: string
}
): Promise<string> {
const getPaymentMethodId = (
pm: string | Stripe.PaymentMethod | null | undefined
): string | undefined => (typeof pm === 'string' ? pm : pm?.id)
let defaultPaymentMethod: string | undefined
try {
const stripeSub = await stripe.subscriptions.retrieve(params.stripeSubscriptionId)
const subDpm = getPaymentMethodId(stripeSub.default_payment_method)
if (subDpm) {
defaultPaymentMethod = subDpm
} else {
const custObj = await stripe.customers.retrieve(params.customerId)
if (custObj && !('deleted' in custObj)) {
const cust = custObj as Stripe.Customer
const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method)
if (custDpm) defaultPaymentMethod = custDpm
}
}
} catch (e) {
logger.error('Failed to retrieve subscription or customer', { error: e })
}
const invoice = await stripe.invoices.create(
{
customer: params.customerId,
collection_method: 'charge_automatically',
auto_advance: false,
description: params.description,
metadata: params.metadata,
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
},
{ idempotencyKey: `${params.idempotencyKey}-invoice` }
)
await stripe.invoiceItems.create(
{
customer: params.customerId,
invoice: invoice.id,
amount: params.amountCents,
currency: 'usd',
description: params.itemDescription,
metadata: params.metadata,
},
{ idempotencyKey: params.idempotencyKey }
)
if (invoice.id) {
const finalized = await stripe.invoices.finalizeInvoice(invoice.id)
if (finalized.status === 'open' && finalized.id) {
try {
await stripe.invoices.pay(finalized.id, {
payment_method: defaultPaymentMethod,
})
} catch (payError) {
logger.error('Failed to auto-pay threshold overage invoice', {
error: payError,
invoiceId: finalized.id,
})
}
}
}
return invoice.id || ''
}
export async function checkAndBillOverageThreshold(userId: string): Promise<void> {
try {
const threshold = OVERAGE_THRESHOLD
@@ -128,10 +42,12 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
return
}
if (isTeam(userSubscription.plan)) {
logger.debug('Team plan detected - triggering org-level threshold billing', {
// Org-scoped subs are billed at the org level regardless of plan name.
if (isOrgScopedSubscription(userSubscription, userId)) {
logger.debug('Org-scoped subscription detected - triggering org-level threshold billing', {
userId,
organizationId: userSubscription.referenceId,
plan: userSubscription.plan,
})
await checkAndBillOrganizationOverageThreshold(userSubscription.referenceId)
return
@@ -160,7 +76,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
periodStart: userSubscription.periodStart,
periodEnd: userSubscription.periodEnd,
})
const billedOverageThisPeriod = parseDecimal(stats.billedOverageThisPeriod)
const billedOverageThisPeriod = toNumber(toDecimal(stats.billedOverageThisPeriod))
const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod)
logger.debug('Threshold billing check', {
@@ -176,14 +92,30 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
return
}
const stripeSubscriptionId = userSubscription.stripeSubscriptionId
if (!stripeSubscriptionId) {
logger.error('No Stripe subscription ID found', { userId })
return
}
const customerRows = await tx
.select({ stripeCustomerId: subscription.stripeCustomerId })
.from(subscription)
.where(eq(subscription.id, userSubscription.id))
.limit(1)
const customerId = customerRows[0]?.stripeCustomerId
if (!customerId) {
logger.error('No Stripe customer ID found', { userId, subscriptionId: userSubscription.id })
return
}
// Apply credits to reduce the amount to bill (use stats from locked row)
let amountToBill = unbilledOverage
let creditsApplied = 0
const creditBalance = Number.parseFloat(stats.creditBalance?.toString() || '0')
const creditBalance = toNumber(toDecimal(stats.creditBalance))
if (creditBalance > 0) {
creditsApplied = Math.min(creditBalance, amountToBill)
// Update credit balance within the transaction
await tx
.update(userStats)
.set({
@@ -200,7 +132,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
})
}
// If credits covered everything, just update the billed amount but don't create invoice
// If credits covered everything, bump billed tracker but don't enqueue Stripe invoice.
if (amountToBill <= 0) {
await tx
.update(userStats)
@@ -217,53 +149,12 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
return
}
const stripeSubscriptionId = userSubscription.stripeSubscriptionId
if (!stripeSubscriptionId) {
logger.error('No Stripe subscription ID found', { userId })
return
}
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
const customerId =
typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id
const periodEnd = userSubscription.periodEnd
? Math.floor(userSubscription.periodEnd.getTime() / 1000)
: Math.floor(Date.now() / 1000)
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
const amountCents = Math.round(amountToBill * 100)
const totalOverageCents = Math.round(currentOverage * 100)
const idempotencyKey = `threshold-overage:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`
logger.info('Creating threshold overage invoice', {
userId,
plan: userSubscription.plan,
amountToBill,
billingPeriod,
idempotencyKey,
})
const cents = amountCents
const invoiceId = await createAndFinalizeOverageInvoice(stripe, {
customerId,
stripeSubscriptionId,
amountCents: cents,
description: `Threshold overage billing ${billingPeriod}`,
itemDescription: `Usage overage ($${amountToBill.toFixed(2)})`,
metadata: {
type: 'overage_threshold_billing',
userId,
subscriptionId: stripeSubscriptionId,
billingPeriod,
totalOverageAtTimeOfBilling: currentOverage.toFixed(2),
},
idempotencyKey,
})
await tx
.update(userStats)
@@ -272,12 +163,31 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
})
.where(eq(userStats.userId, userId))
logger.info('Successfully created and finalized threshold overage invoice', {
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, {
customerId,
stripeSubscriptionId,
amountCents,
description: `Threshold overage billing ${billingPeriod}`,
itemDescription: `Usage overage ($${amountToBill.toFixed(2)})`,
billingPeriod,
invoiceIdemKeyStem: `threshold-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
itemIdemKeyStem: `threshold-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
metadata: {
type: 'overage_threshold_billing',
userId,
subscriptionId: stripeSubscriptionId,
billingPeriod,
totalOverageAtTimeOfBilling: currentOverage.toFixed(2),
},
})
logger.info('Queued threshold overage invoice for Stripe', {
userId,
plan: userSubscription.plan,
amountToBill,
billingPeriod,
creditsApplied,
amountBilled: amountToBill,
totalProcessed: unbilledOverage,
invoiceId,
newBilledTotal: billedOverageThisPeriod + unbilledOverage,
})
})
@@ -304,23 +214,12 @@ export async function checkAndBillOrganizationOverageThreshold(
logger.debug('Starting organization threshold billing check', { organizationId, threshold })
const orgSubscriptions = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
const orgSubscription = await getOrganizationSubscriptionUsable(organizationId)
if (orgSubscriptions.length === 0) {
if (!orgSubscription) {
logger.debug('No active subscription for organization', { organizationId })
return
}
const orgSubscription = orgSubscriptions[0]
logger.debug('Found organization subscription', {
organizationId,
plan: orgSubscription.plan,
@@ -328,8 +227,8 @@ export async function checkAndBillOrganizationOverageThreshold(
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
})
if (!isTeam(orgSubscription.plan)) {
logger.debug('Organization plan is not team, skipping', {
if (isEnterprise(orgSubscription.plan) || isFree(orgSubscription.plan)) {
logger.debug('Organization plan not eligible for overage billing, skipping', {
organizationId,
plan: orgSubscription.plan,
})
@@ -389,9 +288,9 @@ export async function checkAndBillOrganizationOverageThreshold(
return
}
let totalTeamUsage = parseDecimal(ownerStatsLock[0].currentPeriodCost)
const totalBilledOverage = parseDecimal(ownerStatsLock[0].billedOverageThisPeriod)
const orgCreditBalance = Number.parseFloat(orgLock[0].creditBalance?.toString() || '0')
let pooledCurrentPeriodCost = toNumber(toDecimal(ownerStatsLock[0].currentPeriodCost))
const totalBilledOverage = toNumber(toDecimal(ownerStatsLock[0].billedOverageThisPeriod))
const orgCreditBalance = toNumber(toDecimal(orgLock[0].creditBalance))
const nonOwnerIds = members.filter((m) => m.userId !== owner.userId).map((m) => m.userId)
@@ -405,34 +304,33 @@ export async function checkAndBillOrganizationOverageThreshold(
.where(inArray(userStats.userId, nonOwnerIds))
for (const stats of memberStatsRows) {
totalTeamUsage += parseDecimal(stats.currentPeriodCost)
pooledCurrentPeriodCost += toNumber(toDecimal(stats.currentPeriodCost))
}
}
let dailyRefreshDeduction = 0
if (isPaid(orgSubscription.plan) && orgSubscription.periodStart) {
const planDollars = getPlanTierDollars(orgSubscription.plan)
if (planDollars > 0) {
const allMemberIds = members.map((m) => m.userId)
dailyRefreshDeduction = await computeDailyRefreshConsumed({
userIds: allMemberIds,
periodStart: orgSubscription.periodStart,
periodEnd: orgSubscription.periodEnd ?? null,
planDollars,
seats: orgSubscription.seats ?? 1,
})
}
}
const departedMemberUsage = toNumber(toDecimal(orgLock[0].departedMemberUsage))
const {
totalOverage: currentOverage,
baseSubscriptionAmount: basePrice,
effectiveUsage: effectiveTeamUsage,
} = await computeOrgOverageAmount({
plan: orgSubscription.plan,
seats: orgSubscription.seats ?? null,
periodStart: orgSubscription.periodStart ?? null,
periodEnd: orgSubscription.periodEnd ?? null,
organizationId,
pooledCurrentPeriodCost,
departedMemberUsage,
memberIds: members.map((m) => m.userId),
})
const effectiveTeamUsage = Math.max(0, totalTeamUsage - dailyRefreshDeduction)
const { basePrice: basePricePerSeat } = getPlanPricing(orgSubscription.plan)
const basePrice = basePricePerSeat * (orgSubscription.seats ?? 0)
const currentOverage = Math.max(0, effectiveTeamUsage - basePrice)
const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage)
logger.debug('Organization threshold billing check', {
organizationId,
totalTeamUsage,
totalTeamUsage: pooledCurrentPeriodCost + departedMemberUsage,
effectiveTeamUsage,
basePrice,
currentOverage,
totalBilledOverage,
@@ -444,13 +342,24 @@ export async function checkAndBillOrganizationOverageThreshold(
return
}
// Apply credits to reduce the amount to bill (use locked org's balance)
// Validate Stripe identifiers BEFORE mutating credits/trackers.
const stripeSubscriptionId = orgSubscription.stripeSubscriptionId
if (!stripeSubscriptionId) {
logger.error('No Stripe subscription ID for organization', { organizationId })
return
}
const customerId = orgSubscription.stripeCustomerId
if (!customerId) {
logger.error('No Stripe customer ID for organization', { organizationId })
return
}
let amountToBill = unbilledOverage
let creditsApplied = 0
if (orgCreditBalance > 0) {
creditsApplied = Math.min(orgCreditBalance, amountToBill)
// Update credit balance within the transaction
await tx
.update(organization)
.set({
@@ -467,7 +376,7 @@ export async function checkAndBillOrganizationOverageThreshold(
})
}
// If credits covered everything, just update the billed amount but don't create invoice
// If credits covered everything, bump billed tracker but don't enqueue Stripe invoice.
if (amountToBill <= 0) {
await tx
.update(userStats)
@@ -484,19 +393,6 @@ export async function checkAndBillOrganizationOverageThreshold(
return
}
const stripeSubscriptionId = orgSubscription.stripeSubscriptionId
if (!stripeSubscriptionId) {
logger.error('No Stripe subscription ID for organization', { organizationId })
return
}
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
const customerId =
typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id
const periodEnd = orgSubscription.periodEnd
? Math.floor(orgSubscription.periodEnd.getTime() / 1000)
: Math.floor(Date.now() / 1000)
@@ -504,33 +400,8 @@ export async function checkAndBillOrganizationOverageThreshold(
const amountCents = Math.round(amountToBill * 100)
const totalOverageCents = Math.round(currentOverage * 100)
const idempotencyKey = `threshold-overage-org:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`
logger.info('Creating organization threshold overage invoice', {
organizationId,
amountToBill,
creditsApplied,
billingPeriod,
})
const cents = amountCents
const invoiceId = await createAndFinalizeOverageInvoice(stripe, {
customerId,
stripeSubscriptionId,
amountCents: cents,
description: `Team threshold overage billing ${billingPeriod}`,
itemDescription: `Team usage overage ($${amountToBill.toFixed(2)})`,
metadata: {
type: 'overage_threshold_billing_org',
organizationId,
subscriptionId: stripeSubscriptionId,
billingPeriod,
totalOverageAtTimeOfBilling: currentOverage.toFixed(2),
},
idempotencyKey,
})
// Bump billed tracker and enqueue Stripe invoice atomically.
// See user-path above for the full retry-invariant reasoning.
await tx
.update(userStats)
.set({
@@ -538,13 +409,31 @@ export async function checkAndBillOrganizationOverageThreshold(
})
.where(eq(userStats.userId, owner.userId))
logger.info('Successfully created and finalized organization threshold overage invoice', {
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, {
customerId,
stripeSubscriptionId,
amountCents,
description: `Team threshold overage billing ${billingPeriod}`,
itemDescription: `Team usage overage ($${amountToBill.toFixed(2)})`,
billingPeriod,
invoiceIdemKeyStem: `threshold-overage-org-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
itemIdemKeyStem: `threshold-overage-org-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
metadata: {
type: 'overage_threshold_billing_org',
organizationId,
subscriptionId: stripeSubscriptionId,
billingPeriod,
totalOverageAtTimeOfBilling: currentOverage.toFixed(2),
},
})
logger.info('Queued organization threshold overage invoice for Stripe', {
organizationId,
ownerId: owner.userId,
creditsApplied,
amountBilled: amountToBill,
totalProcessed: unbilledOverage,
invoiceId,
billingPeriod,
})
})
} catch (error) {
+9
View File
@@ -62,6 +62,15 @@ export interface UsageLimitInfo {
minimumLimit: number
plan: string
updatedAt: Date | null
/**
* Whether the limit is stored on the user (`'user'`) or the organization
* (`'organization'`). Callers should route edits to the matching API
* context. Org-scoped includes any subscription whose `referenceId` is
* an organization id, regardless of plan name.
*/
scope: 'user' | 'organization'
/** Present only when `scope === 'organization'`. */
organizationId: string | null
}
export interface BillingData {
@@ -3,7 +3,7 @@ import { invitation, member, organization, subscription, user, userStats } from
import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { isEnterprise, isFree, isPro } from '@/lib/billing/plan-helpers'
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -62,11 +62,10 @@ export async function validateSeatAvailability(
}
}
// Free and Pro plans don't support organizations
if (isFree(subscription.plan) || isPro(subscription.plan)) {
if (isFree(subscription.plan)) {
return {
canInvite: false,
reason: 'Organization features require Team or Enterprise plan',
reason: 'Organization features require a paid plan',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
@@ -81,9 +80,8 @@ export async function validateSeatAvailability(
const currentSeats = memberCount[0]?.count || 0
// Determine seat limits based on subscription
// Team: seats from Stripe subscription quantity (seats column)
// Enterprise: seats from metadata.seats (not from seats column which is always 1)
// Team: seats from the `seats` column (Stripe quantity).
// Enterprise: seats from metadata.seats (column is always 1).
const maxSeats = getEffectiveSeats(subscription)
const availableSeats = Math.max(0, maxSeats - currentSeats)
@@ -156,7 +154,6 @@ export async function getOrganizationSeatInfo(
const currentSeats = memberCount[0]?.count || 0
// Team: seats from column, Enterprise: seats from metadata
const maxSeats = getEffectiveSeats(subscription)
const canAddSeats = !isEnterprise(subscription.plan)
@@ -0,0 +1,39 @@
import { IdempotencyService } from '@/lib/core/idempotency/service'
/**
* Idempotency service for Stripe webhook handlers.
*
* Stripe delivers webhook events at-least-once and retries failed
* deliveries for up to 3 days. Handlers that perform non-idempotent work
* (crediting accounts, removing credits, resetting usage trackers, etc.)
* must be wrapped in a claim so duplicate deliveries are collapsed to a
* single execution.
*
* Storage is **forced to Postgres** regardless of whether Redis is
* configured. Billing handlers mutate `user_stats` / `organization` /
* `subscription` rows via DB transactions — keeping the idempotency
* record in the same Postgres closes the narrow window where the
* operation commits but a Redis `storeResult` fails, which would cause
* Stripe's next retry to re-run the money-affecting work. The latency
* cost (15 ms per claim/store) is invisible on webhook responses, and
* volume is low enough (roughly one event per customer per billing
* cycle) that DB storage scales comfortably.
*
* `retryFailures: true` means a thrown handler releases the claim so
* Stripe's next retry runs from scratch — without it, one transient
* failure would poison the key for the whole TTL window.
*
* TTL of 7 days is slightly longer than Stripe's 3-day retry horizon so
* late retries still dedupe against completed work. Rows past their TTL
* are handled two ways: `atomicallyClaimDb` reclaims stale rows inline
* via `ON CONFLICT DO UPDATE WHERE created_at < expired_before` (so
* correctness does not depend on cleanup running), and the external
* cleanup cron (scheduled from the infra repo) hits
* `/api/webhooks/cleanup/idempotency` to bound table size.
*/
export const stripeWebhookIdempotency = new IdempotencyService({
namespace: 'stripe-webhook',
ttlSeconds: 60 * 60 * 24 * 7,
retryFailures: true,
forceStorage: 'database',
})
@@ -92,6 +92,7 @@ vi.mock('@/components/emails', () => ({
vi.mock('@/lib/billing/core/billing', () => ({
calculateSubscriptionOverage: vi.fn(),
isSubscriptionOrgScoped: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/billing/credits/balance', () => ({
@@ -119,6 +120,36 @@ vi.mock('@/lib/billing/stripe-client', () => ({
requireStripeClient: vi.fn(),
}))
vi.mock('@/lib/billing/stripe-payment-method', () => ({
resolveDefaultPaymentMethod: vi.fn(async () => ({
paymentMethodId: undefined,
collectionMethod: 'charge_automatically',
})),
getPaymentMethodId: vi.fn(),
getCustomerId: vi.fn(),
}))
vi.mock('@/lib/billing/subscriptions/utils', () => ({
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing', 'past_due'],
}))
vi.mock('@/lib/billing/utils/decimal', () => ({
toDecimal: vi.fn((v: string | number | null | undefined) => {
if (v === null || v === undefined || v === '') return { toNumber: () => 0 }
return { toNumber: () => Number(v) }
}),
toNumber: vi.fn((d: { toNumber: () => number }) => d.toNumber()),
}))
vi.mock('@/lib/billing/webhooks/idempotency', () => ({
stripeWebhookIdempotency: {
executeWithIdempotency: vi.fn(
async (_provider: string, _identifier: string, operation: () => Promise<unknown>) =>
operation()
),
},
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'https://sim.test'),
}))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
import { db } from '@sim/db'
import { subscription as subscriptionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method'
import type { OutboxHandler } from '@/lib/core/outbox/service'
const logger = createLogger('BillingOutboxHandlers')
export const OUTBOX_EVENT_TYPES = {
/**
* Sync a subscription's `cancel_at_period_end` flag from our DB to
* Stripe. The handler reads the current DB value at processing time
* — so rapid cancel→uncancel→cancel sequences always converge on
* the last-committed DB state regardless of outbox ordering. Callers
* enqueue this event after every DB change to `cancelAtPeriodEnd`.
*/
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice',
} as const
export interface StripeSyncCancelAtPeriodEndPayload {
stripeSubscriptionId: string
/** The DB subscription row id — also our source-of-truth pointer. */
subscriptionId: string
/** Optional: reason this was enqueued — e.g. 'member-joined-paid-org'. */
reason?: string
}
export interface StripeThresholdOverageInvoicePayload {
customerId: string
stripeSubscriptionId: string
amountCents: number
description: string
itemDescription: string
billingPeriod: string
/** Stripe idempotency key stem — we append the outbox event id for per-retry safety. */
invoiceIdemKeyStem: string
itemIdemKeyStem: string
metadata?: Record<string, string>
}
const stripeSyncCancelAtPeriodEnd: OutboxHandler<StripeSyncCancelAtPeriodEndPayload> = async (
payload,
ctx
) => {
// Read the DB value at processing time (not at enqueue time). This
// makes the handler idempotent across racing enqueues: multiple
// events for the same subscription all push whatever the DB
// currently says, converging on the last committed value.
const rows = await db
.select({ cancelAtPeriodEnd: subscriptionTable.cancelAtPeriodEnd })
.from(subscriptionTable)
.where(eq(subscriptionTable.id, payload.subscriptionId))
.limit(1)
if (rows.length === 0) {
logger.warn('Subscription not found when syncing cancel_at_period_end', {
subscriptionId: payload.subscriptionId,
})
return
}
const desiredValue = Boolean(rows[0].cancelAtPeriodEnd)
const stripe = requireStripeClient()
await stripe.subscriptions.update(
payload.stripeSubscriptionId,
{ cancel_at_period_end: desiredValue },
{ idempotencyKey: `outbox:${ctx.eventId}` }
)
logger.info('Synced cancel_at_period_end from DB to Stripe', {
eventId: ctx.eventId,
stripeSubscriptionId: payload.stripeSubscriptionId,
subscriptionId: payload.subscriptionId,
desiredValue,
reason: payload.reason,
})
}
const stripeThresholdOverageInvoice: OutboxHandler<StripeThresholdOverageInvoicePayload> = async (
payload,
ctx
) => {
const stripe = requireStripeClient()
// Resolve default PM from (subscription → customer) so Stripe can
// auto-collect when the invoice finalizes. Without this, an ad-hoc
// invoice (no subscription link) falls back to customer-level PM
// only, which may not be set for customers onboarded via Checkout
// Subscription flows.
const { paymentMethodId: defaultPaymentMethod } = await resolveDefaultPaymentMethod(
stripe,
payload.stripeSubscriptionId,
payload.customerId
)
// Compose Stripe idempotency keys from caller-provided stem + outbox
// event id so retries of the SAME outbox event collapse on Stripe's
// side.
const invoiceIdemKey = `${payload.invoiceIdemKeyStem}:${ctx.eventId}`
const itemIdemKey = `${payload.itemIdemKeyStem}:${ctx.eventId}`
const finalizeIdemKey = `${payload.invoiceIdemKeyStem}:finalize:${ctx.eventId}`
const payIdemKey = `${payload.invoiceIdemKeyStem}:pay:${ctx.eventId}`
// `auto_advance: false` + explicit finalize mirrors pre-refactor
// behavior: we control exactly when the invoice finalizes, so it
// doesn't silently convert to paid/open on Stripe's schedule while
// our retry state is still in flight.
const invoice = await stripe.invoices.create(
{
customer: payload.customerId,
collection_method: 'charge_automatically',
auto_advance: false,
description: payload.description,
metadata: payload.metadata,
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
},
{ idempotencyKey: invoiceIdemKey }
)
if (!invoice.id) {
throw new Error('Stripe returned invoice without id')
}
await stripe.invoiceItems.create(
{
customer: payload.customerId,
invoice: invoice.id,
amount: payload.amountCents,
currency: 'usd',
description: payload.itemDescription,
metadata: payload.metadata,
},
{ idempotencyKey: itemIdemKey }
)
const finalized = await stripe.invoices.finalizeInvoice(
invoice.id,
{},
{ idempotencyKey: finalizeIdemKey }
)
if (finalized.status === 'open' && finalized.id && defaultPaymentMethod) {
try {
await stripe.invoices.pay(
finalized.id,
{ payment_method: defaultPaymentMethod },
{ idempotencyKey: payIdemKey }
)
} catch (payError) {
logger.warn('Auto-pay failed for threshold overage invoice — Stripe dunning will retry', {
invoiceId: finalized.id,
error: payError instanceof Error ? payError.message : payError,
})
}
}
logger.info('Created threshold overage invoice via outbox', {
eventId: ctx.eventId,
invoiceId: invoice.id,
customerId: payload.customerId,
amountCents: payload.amountCents,
billingPeriod: payload.billingPeriod,
defaultPaymentMethod: defaultPaymentMethod ? 'resolved' : 'none',
})
}
export const billingOutboxHandlers = {
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END]:
stripeSyncCancelAtPeriodEnd as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE]:
stripeThresholdOverageInvoice as OutboxHandler<unknown>,
} as const
+180 -167
View File
@@ -2,13 +2,14 @@ import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing'
import { hasPaidSubscription } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
import { isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { isEnterprise, isPaid, isPro } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
import {
getBilledOverageForSubscription,
resetUsageForSubscription,
@@ -175,194 +176,206 @@ export async function handleSubscriptionCreated(subscriptionData: {
}
/**
* Handle subscription deletion/cancellation - bill for final period overages
* This fires when a subscription reaches its cancel_at_period_end date or is cancelled immediately
* Handle subscription deletion/cancellation bill for final period
* overages, reset usage, restore member Pros, and clean up the org.
*
* Wrapped in `stripeWebhookIdempotency` keyed by Stripe `event.id` so
* that duplicate webhook deliveries collapse to a single execution. The
* three failure-prone side effects each have their own recovery story:
* - Final Stripe invoice: created with a deterministic idempotency key,
* so re-create returns the existing invoice on retry
* - `resetUsageForSubscription`: delta-based reset, near-idempotent
* - `cleanupOrganizationSubscription`: delete is idempotent (ON NOT
* EXISTS); Pro restore flips `cancelAtPeriodEnd=false`, idempotent
* If any step throws, `retryFailures: true` releases the claim so
* Stripe's next retry runs from scratch and recovers.
*/
export async function handleSubscriptionDeleted(subscription: {
id: string
plan: string | null
referenceId: string
stripeSubscriptionId: string | null
seats?: number | null
}) {
export async function handleSubscriptionDeleted(
subscription: {
id: string
plan: string | null
referenceId: string
stripeSubscriptionId: string | null
seats?: number | null
},
stripeEventId?: string
) {
const stripeSubscriptionId = subscription.stripeSubscriptionId || ''
logger.info('Processing subscription deletion', {
stripeEventId,
stripeSubscriptionId,
subscriptionId: subscription.id,
})
// Fall back to the subscription DB id when we don't have an event id
// (e.g. called outside the Stripe webhook context). Still dedupes a
// single subscription's deletion, just not event-granular.
const idempotencyIdentifier = stripeEventId ?? `sub:${subscription.id}`
try {
const stripeSubscriptionId = subscription.stripeSubscriptionId || ''
await stripeWebhookIdempotency.executeWithIdempotency(
'subscription-deleted',
idempotencyIdentifier,
async () => {
const totalOverage = await calculateSubscriptionOverage(subscription)
const stripe = requireStripeClient()
logger.info('Processing subscription deletion', {
stripeSubscriptionId,
subscriptionId: subscription.id,
})
// Enterprise plans have no overages — reset usage and cleanup org
if (isEnterprise(subscription.plan)) {
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
})
// Calculate overage for the final billing period
const totalOverage = await calculateSubscriptionOverage(subscription)
const stripe = requireStripeClient()
const { restoredProCount, membersSynced, organizationDeleted } =
await cleanupOrganizationSubscription(subscription.referenceId)
// Enterprise plans have no overages - reset usage and cleanup org
if (isEnterprise(subscription.plan)) {
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
})
logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
restoredProCount,
organizationDeleted,
membersSynced,
})
const { restoredProCount, membersSynced, organizationDeleted } =
await cleanupOrganizationSubscription(subscription.referenceId)
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {
plan: subscription.plan ?? 'unknown',
reference_id: subscription.referenceId,
})
logger.info('Successfully processed enterprise subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
restoredProCount,
organizationDeleted,
membersSynced,
})
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {
plan: subscription.plan ?? 'unknown',
reference_id: subscription.referenceId,
})
return
}
// Get already-billed overage from threshold billing
const billedOverage = await getBilledOverageForSubscription(subscription)
// Only bill the remaining unbilled overage
const remainingOverage = Math.max(0, totalOverage - billedOverage)
logger.info('Subscription deleted overage calculation', {
subscriptionId: subscription.id,
totalOverage,
billedOverage,
remainingOverage,
})
// Create final overage invoice if needed
if (remainingOverage > 0 && stripeSubscriptionId) {
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
const customerId = stripeSubscription.customer as string
const cents = Math.round(remainingOverage * 100)
// Use the subscription end date for the billing period
const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000)
const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7)
const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
try {
// Create a one-time invoice for the final overage
const overageInvoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: 'charge_automatically',
auto_advance: true, // Auto-finalize and attempt payment
description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`,
metadata: {
type: 'final_overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
cancelledAt: stripeSubscription.canceled_at?.toString() || '',
},
},
{ idempotencyKey: invoiceIdemKey }
)
// Add the overage line item
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: overageInvoice.id,
amount: cents,
currency: 'usd',
description: `Usage overage for ${subscription.plan} plan (Final billing period)`,
metadata: {
type: 'final_usage_overage',
usage: remainingOverage.toFixed(2),
totalOverage: totalOverage.toFixed(2),
billedOverage: billedOverage.toFixed(2),
billingPeriod,
},
},
{ idempotencyKey: itemIdemKey }
)
// Finalize the invoice (this will trigger payment collection)
if (overageInvoice.id) {
await stripe.invoices.finalizeInvoice(overageInvoice.id)
return { totalOverage: 0, kind: 'enterprise' as const }
}
logger.info('Created final overage invoice for cancelled subscription', {
const billedOverage = await getBilledOverageForSubscription(subscription)
const remainingOverage = Math.max(0, totalOverage - billedOverage)
logger.info('Subscription deleted overage calculation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
invoiceId: overageInvoice.id,
totalOverage,
billedOverage,
remainingOverage,
cents,
billingPeriod,
})
} catch (invoiceError) {
logger.error('Failed to create final overage invoice', {
// Phase — Stripe final overage invoice. Idempotency keys ensure
// retry-safe creation; errors propagate up to the wrapper so the
// webhook gets retried rather than swallowed.
if (remainingOverage > 0 && stripeSubscriptionId) {
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
const customerId = stripeSubscription.customer as string
const cents = Math.round(remainingOverage * 100)
const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000)
const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7)
const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const finalizeIdemKey = `final-overage-finalize:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const overageInvoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: 'charge_automatically',
auto_advance: true,
description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`,
metadata: {
type: 'final_overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
cancelledAt: stripeSubscription.canceled_at?.toString() || '',
},
},
{ idempotencyKey: invoiceIdemKey }
)
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: overageInvoice.id,
amount: cents,
currency: 'usd',
description: `Usage overage for ${subscription.plan} plan (Final billing period)`,
metadata: {
type: 'final_usage_overage',
usage: remainingOverage.toFixed(2),
totalOverage: totalOverage.toFixed(2),
billedOverage: billedOverage.toFixed(2),
billingPeriod,
},
},
{ idempotencyKey: itemIdemKey }
)
if (overageInvoice.id) {
await stripe.invoices.finalizeInvoice(
overageInvoice.id,
{},
{ idempotencyKey: finalizeIdemKey }
)
}
logger.info('Created final overage invoice for cancelled subscription', {
subscriptionId: subscription.id,
stripeSubscriptionId,
invoiceId: overageInvoice.id,
totalOverage,
billedOverage,
remainingOverage,
cents,
billingPeriod,
})
} else {
logger.info('No overage to bill for cancelled subscription', {
subscriptionId: subscription.id,
plan: subscription.plan,
})
}
// Phase — reset usage, then plan-specific cleanup. Both are
// idempotent on re-run (delete is already-no-op if org is gone;
// reset-by-delta is a no-op when trackers are already zeroed).
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
})
let restoredProCount = 0
let organizationDeleted = false
let membersSynced = 0
if (await isSubscriptionOrgScoped(subscription)) {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced
organizationDeleted = cleanup.organizationDeleted
} else if (isPro(subscription.plan)) {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1
}
logger.info('Successfully processed subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
plan: subscription.plan,
totalOverage,
billedOverage,
remainingOverage,
error: invoiceError,
restoredProCount,
organizationDeleted,
membersSynced,
})
// Don't throw - we don't want to fail the webhook
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {
plan: subscription.plan ?? 'unknown',
reference_id: subscription.referenceId,
})
return { totalOverage, remainingOverage, restoredProCount, organizationDeleted }
}
} else {
logger.info('No overage to bill for cancelled subscription', {
subscriptionId: subscription.id,
plan: subscription.plan,
})
}
// Reset usage after billing
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
})
// Plan-specific cleanup after billing
let restoredProCount = 0
let organizationDeleted = false
let membersSynced = 0
if (isTeam(subscription.plan)) {
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
restoredProCount = cleanup.restoredProCount
membersSynced = cleanup.membersSynced
organizationDeleted = cleanup.organizationDeleted
} else if (isPro(subscription.plan)) {
await syncUsageLimitsFromSubscription(subscription.referenceId)
membersSynced = 1
}
// Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler
// We handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup
logger.info('Successfully processed subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
plan: subscription.plan,
totalOverage,
restoredProCount,
organizationDeleted,
membersSynced,
})
captureServerEvent(subscription.referenceId, 'subscription_cancelled', {
plan: subscription.plan ?? 'unknown',
reference_id: subscription.referenceId,
})
)
} catch (error) {
logger.error('Failed to handle subscription deletion', {
subscriptionId: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId || '',
stripeSubscriptionId,
error,
})
throw error // Re-throw to signal webhook failure for retry
throw error
}
}
+16 -4
View File
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isPaid } from '@/lib/billing/plan-helpers'
import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
@@ -29,14 +30,25 @@ export async function handleBillingLimitResponse(
execContext: ExecutionContext,
options: OrchestratorOptions
): Promise<void> {
let action = 'upgrade_plan'
let action: 'upgrade_plan' | 'increase_limit' = 'upgrade_plan'
let message = "You've reached your usage limit. Please upgrade your plan to continue."
try {
const sub = await getHighestPrioritySubscription(userId)
if (sub && isPaid(sub.plan)) {
// Paid subs use the existing `increase_limit` action so the UI
// (`UsageUpgradeDisplay`) renders its standard button. The message
// text does the work of clarifying the action when the user can't
// actually self-serve the limit change.
action = 'increase_limit'
message =
"You've reached your usage limit for this billing period. Please increase your usage limit to continue."
const orgScoped = isOrgScopedSubscription(sub, userId)
if (orgScoped) {
message = isEnterprise(sub.plan)
? "You've reached your organization's usage limit for this billing period. Only an organization admin or Sim support can raise an enterprise limit — reach out to them to continue."
: "You've reached your organization's usage limit for this billing period. Only an organization owner or admin can raise the limit — please ask them to update it from the team billing settings."
} else {
message =
"You've reached your usage limit for this billing period. Please increase your usage limit from billing settings to continue."
}
}
} catch {
logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan')
+50 -7
View File
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { idempotencyKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { eq, lt } from 'drizzle-orm'
import { getRedisClient } from '@/lib/core/config/redis'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getStorageMethod, type StorageMethod } from '@/lib/core/storage'
@@ -16,6 +16,20 @@ export interface IdempotencyConfig {
namespace?: string
/** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */
retryFailures?: boolean
/**
* Force a specific storage backend regardless of the environment's
* auto-detection. Use `'database'` for correctness-critical flows
* (money, billing, compliance) where the claim + operation should
* fate-share with the Postgres transaction this closes the narrow
* window where the operation commits to DB but `storeResult` to Redis
* fails and the retry re-runs the operation. Latency cost is 15ms
* per call, imperceptible on webhook code paths.
*
* Leave unset (or set `'redis'`) for latency-sensitive, high-volume
* flows like app webhook triggers where the scale benefits of Redis
* outweigh the narrow durability window.
*/
forceStorage?: StorageMethod
}
export interface IdempotencyResult {
@@ -50,11 +64,12 @@ const POLL_INTERVAL_MS = 1000
* that need duplicate prevention.
*
* Storage is determined once based on configuration:
* - If REDIS_URL is set Redis
* - If REDIS_URL is not set PostgreSQL
* - If `forceStorage` is set that backend unconditionally
* - Else if `REDIS_URL` is set Redis
* - Else PostgreSQL
*/
export class IdempotencyService {
private config: Required<IdempotencyConfig>
private config: Required<Omit<IdempotencyConfig, 'forceStorage'>>
private storageMethod: StorageMethod
constructor(config: IdempotencyConfig = {}) {
@@ -63,9 +78,10 @@ export class IdempotencyService {
namespace: config.namespace ?? 'default',
retryFailures: config.retryFailures ?? false,
}
this.storageMethod = getStorageMethod()
this.storageMethod = config.forceStorage ?? getStorageMethod()
logger.info(`IdempotencyService using ${this.storageMethod} storage`, {
namespace: this.config.namespace,
forced: Boolean(config.forceStorage),
})
}
@@ -220,15 +236,31 @@ export class IdempotencyService {
normalizedKey: string,
inProgressResult: ProcessingResult
): Promise<AtomicClaimResult> {
const now = new Date()
const expiredBefore = new Date(now.getTime() - this.config.ttlSeconds * 1000)
// `ON CONFLICT DO UPDATE WHERE created_at < expiredBefore` steals the
// claim when the existing row has outlived the TTL (e.g. a prior
// holder crashed mid-operation and never wrote `completed`/`failed`
// or released the key). RETURNING yields a row in two cases:
// (1) fresh INSERT — no prior row existed;
// (2) UPDATE of an expired row — WHERE matched.
// An empty RETURNING means conflict with an unexpired row; the
// existing holder is still live and we must not steal.
const insertResult = await db
.insert(idempotencyKey)
.values({
key: normalizedKey,
result: inProgressResult,
createdAt: new Date(),
createdAt: now,
})
.onConflictDoNothing({
.onConflictDoUpdate({
target: [idempotencyKey.key],
set: {
result: inProgressResult,
createdAt: now,
},
setWhere: lt(idempotencyKey.createdAt, expiredBefore),
})
.returning({ key: idempotencyKey.key })
@@ -489,7 +521,18 @@ export const pollingIdempotency = new IdempotencyService({
retryFailures: true,
})
/**
* Used by the internal `/api/billing/update-cost` endpoint (copilot,
* workspace-chat, MCP, mothership) to dedupe cost-recording calls. Storage
* is forced to Postgres: the operation writes AI cost to `user_stats`,
* and if Redis evicts the dedup key under memory pressure (high call
* volume) or drops it on restart, a retry would double-record usage
* real money. DB storage fate-shares with `user_stats` and is
* eviction-proof; ~1-5ms added latency is invisible against LLM call
* latency.
*/
export const billingIdempotency = new IdempotencyService({
namespace: 'billing',
ttlSeconds: 60 * 60, // 1 hour
forceStorage: 'database',
})
+385
View File
@@ -0,0 +1,385 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type OutboxRow = {
id: string
eventType: string
payload: unknown
status: 'pending' | 'processing' | 'completed' | 'dead_letter'
attempts: number
maxAttempts: number
availableAt: Date
lockedAt: Date | null
lastError: string | null
createdAt: Date
processedAt: Date | null
}
// Hoisted mock state — all tests manipulate these directly.
const { state, mockDb } = vi.hoisted(() => {
const state = {
// Rows returned from the FOR UPDATE SKIP LOCKED select in claimBatch.
claimedRows: [] as OutboxRow[],
// Whether the terminal update (lease CAS) should report a match.
leaseHeld: true,
// IDs the reaper's UPDATE should return (simulates stuck `processing` rows).
reapedRowIds: [] as string[],
// Everything written (for assertions).
inserts: [] as Array<{ values: unknown }>,
updates: [] as Array<{ set: Record<string, unknown>; where?: unknown }>,
}
const makeUpdateChain = () => {
const row: { set: Record<string, unknown>; where?: unknown } = { set: {} }
const chain: Record<string, unknown> = {}
chain.set = vi.fn((s: Record<string, unknown>) => {
row.set = s
return chain
})
chain.where = vi.fn((w: unknown) => {
row.where = w
state.updates.push(row)
return chain
})
chain.returning = vi.fn(async () => {
// Terminal UPDATE (lease CAS): has `attempts` + `availableAt`
// on retry, or explicit completed/dead_letter. Reaper path sets
// status='pending' without attempts/availableAt.
const isReaperUpdate =
row.set.status === 'pending' && !('attempts' in row.set) && !('availableAt' in row.set)
if (isReaperUpdate) {
return state.reapedRowIds.map((id) => ({ id }))
}
if (
row.set.status === 'completed' ||
row.set.status === 'dead_letter' ||
(row.set.status === 'pending' && 'attempts' in row.set && 'availableAt' in row.set)
) {
return state.leaseHeld ? [{ id: 'evt-1' }] : []
}
return []
})
return chain
}
const makeSelectChain = () => {
const chain: Record<string, unknown> = {}
const self = () => chain
chain.from = vi.fn(self)
chain.where = vi.fn(self)
chain.orderBy = vi.fn(self)
chain.limit = vi.fn(self)
chain.for = vi.fn(async () => state.claimedRows)
return chain
}
const mockDb = {
insert: vi.fn(() => {
const chain: Record<string, unknown> = {}
chain.values = vi.fn(async (v: unknown) => {
state.inserts.push({ values: v })
})
return chain
}),
update: vi.fn(() => makeUpdateChain()),
select: vi.fn(() => makeSelectChain()),
transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(mockDb)),
}
return { state, mockDb }
})
vi.mock('@sim/db', () => ({ db: mockDb }))
vi.mock('@sim/db/schema', () => ({
outboxEvent: {
id: 'outbox_event.id',
eventType: 'outbox_event.event_type',
payload: 'outbox_event.payload',
status: 'outbox_event.status',
attempts: 'outbox_event.attempts',
maxAttempts: 'outbox_event.max_attempts',
availableAt: 'outbox_event.available_at',
lockedAt: 'outbox_event.locked_at',
lastError: 'outbox_event.last_error',
createdAt: 'outbox_event.created_at',
processedAt: 'outbox_event.processed_at',
$inferSelect: {} as OutboxRow,
},
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...args) => ({ _op: 'and', args })),
asc: vi.fn((col) => ({ _op: 'asc', col })),
eq: vi.fn((col, val) => ({ _op: 'eq', col, val })),
inArray: vi.fn((col, vals) => ({ _op: 'inArray', col, vals })),
lte: vi.fn((col, val) => ({ _op: 'lte', col, val })),
}))
vi.mock('@/lib/core/utils/uuid', () => ({
generateId: vi.fn(() => 'test-event-id'),
}))
import { enqueueOutboxEvent, processOutboxEvents } from './service'
function makePendingRow(overrides: Partial<OutboxRow> = {}): OutboxRow {
return {
id: 'evt-1',
eventType: 'test.event',
payload: { foo: 'bar' },
status: 'pending',
attempts: 0,
maxAttempts: 10,
availableAt: new Date(Date.now() - 1000),
lockedAt: null,
lastError: null,
createdAt: new Date(Date.now() - 5000),
processedAt: null,
...overrides,
}
}
function resetState() {
state.claimedRows = []
state.leaseHeld = true
state.reapedRowIds = []
state.inserts.length = 0
state.updates.length = 0
}
describe('enqueueOutboxEvent', () => {
beforeEach(() => {
vi.clearAllMocks()
resetState()
})
it('inserts a row with the given event type and payload', async () => {
const id = await enqueueOutboxEvent(mockDb, 'test.event', { foo: 'bar' })
expect(id).toBe('test-event-id')
expect(state.inserts[0].values).toMatchObject({
id: 'test-event-id',
eventType: 'test.event',
payload: { foo: 'bar' },
maxAttempts: 10,
})
})
it('respects maxAttempts override', async () => {
await enqueueOutboxEvent(mockDb, 'test.event', {}, { maxAttempts: 3 })
expect(state.inserts[0].values).toMatchObject({ maxAttempts: 3 })
})
it('respects availableAt override for delayed processing', async () => {
const future = new Date(Date.now() + 60_000)
await enqueueOutboxEvent(mockDb, 'test.event', {}, { availableAt: future })
expect((state.inserts[0].values as { availableAt: Date }).availableAt).toBe(future)
})
})
describe('processOutboxEvents — empty / no handler', () => {
beforeEach(() => {
vi.clearAllMocks()
resetState()
})
it('returns zero counts when no events are due', async () => {
const result = await processOutboxEvents({})
expect(result).toEqual({
processed: 0,
retried: 0,
deadLettered: 0,
leaseLost: 0,
reaped: 0,
})
})
it('dead-letters events with no registered handler', async () => {
state.claimedRows = [makePendingRow({ eventType: 'unknown.event' })]
const result = await processOutboxEvents({})
expect(result.deadLettered).toBe(1)
const terminal = state.updates.find((u) => u.set.status === 'dead_letter')
expect(terminal).toBeDefined()
expect(terminal?.set.lastError).toMatch(/No handler registered/)
})
})
describe('processOutboxEvents — handler success and retry', () => {
beforeEach(() => {
vi.clearAllMocks()
resetState()
})
it('transitions to completed on handler success and passes context to handler', async () => {
const handlerCalls: Array<{ payload: unknown; eventId: string; attempts: number }> = []
const handler = vi.fn(async (payload: unknown, ctx: { eventId: string; attempts: number }) => {
handlerCalls.push({ payload, eventId: ctx.eventId, attempts: ctx.attempts })
})
state.claimedRows = [makePendingRow()]
const result = await processOutboxEvents({ 'test.event': handler })
expect(result.processed).toBe(1)
expect(handlerCalls).toEqual([{ payload: { foo: 'bar' }, eventId: 'evt-1', attempts: 0 }])
const completeUpdate = state.updates.find((u) => u.set.status === 'completed')
expect(completeUpdate).toBeDefined()
})
it('schedules retry with exponential backoff on handler failure below maxAttempts', async () => {
const handler = vi.fn(async () => {
throw new Error('transient failure')
})
state.claimedRows = [makePendingRow({ attempts: 2 })]
const before = Date.now()
const result = await processOutboxEvents({ 'test.event': handler })
expect(result.retried).toBe(1)
const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set)
expect(retryUpdate).toBeDefined()
expect(retryUpdate?.set.attempts).toBe(3)
expect(retryUpdate?.set.lastError).toBe('transient failure')
// Backoff after nextAttempts=3: 1000 * 2^3 = 8000ms
const scheduledAt = retryUpdate?.set.availableAt as Date
expect(scheduledAt.getTime()).toBeGreaterThan(before + 7500)
expect(scheduledAt.getTime()).toBeLessThan(before + 10_000)
})
it('dead-letters on failure when attempts reaches maxAttempts', async () => {
const handler = vi.fn(async () => {
throw new Error('permanent failure')
})
state.claimedRows = [makePendingRow({ attempts: 9, maxAttempts: 10 })]
const result = await processOutboxEvents({ 'test.event': handler })
expect(result.deadLettered).toBe(1)
const deadUpdate = state.updates.find((u) => u.set.status === 'dead_letter')
expect(deadUpdate).toBeDefined()
expect(deadUpdate?.set.attempts).toBe(10)
expect(deadUpdate?.set.lastError).toBe('permanent failure')
})
it('caps exponential backoff at 1 hour', async () => {
const handler = vi.fn(async () => {
throw new Error('transient')
})
state.claimedRows = [makePendingRow({ attempts: 20, maxAttempts: 100 })]
const before = Date.now()
await processOutboxEvents({ 'test.event': handler })
const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set)
expect(retryUpdate).toBeDefined()
const scheduledAt = retryUpdate?.set.availableAt as Date
// 1hr = 3,600,000ms
expect(scheduledAt.getTime()).toBeLessThan(before + 3_600_000 + 1000)
expect(scheduledAt.getTime()).toBeGreaterThan(before + 3_599_000)
})
})
describe('processOutboxEvents — lease CAS / reaper race', () => {
beforeEach(() => {
vi.clearAllMocks()
resetState()
})
it('reports leaseLost when completion UPDATE affects zero rows', async () => {
const handler = vi.fn(async () => {
// "succeeds" but terminal write will fail the lease CAS
})
state.claimedRows = [makePendingRow()]
state.leaseHeld = false
const result = await processOutboxEvents({ 'test.event': handler })
expect(result.leaseLost).toBe(1)
expect(result.processed).toBe(0)
})
it('reports leaseLost on retry-schedule UPDATE when row was reclaimed', async () => {
const handler = vi.fn(async () => {
throw new Error('transient')
})
state.claimedRows = [makePendingRow({ attempts: 2 })]
state.leaseHeld = false
const result = await processOutboxEvents({ 'test.event': handler })
expect(result.leaseLost).toBe(1)
expect(result.retried).toBe(0)
})
})
describe('processOutboxEvents — handler timeout', () => {
beforeEach(() => {
vi.clearAllMocks()
resetState()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('times out a stuck handler and schedules retry', async () => {
const neverResolves = vi.fn(() => new Promise<void>(() => {}))
state.claimedRows = [makePendingRow({ attempts: 0 })]
const promise = processOutboxEvents({ 'test.event': neverResolves })
// Must exceed DEFAULT_HANDLER_TIMEOUT_MS (90s).
await vi.advanceTimersByTimeAsync(90 * 1000 + 1)
const result = await promise
expect(result.retried).toBe(1)
const retryUpdate = state.updates.find((u) => u.set.status === 'pending' && 'attempts' in u.set)
expect(retryUpdate?.set.lastError).toMatch(/timed out/)
})
})
describe('processOutboxEvents — reaper recovery', () => {
beforeEach(() => {
vi.clearAllMocks()
resetState()
})
it('reaps stuck processing rows back to pending and reports count', async () => {
state.reapedRowIds = ['stuck-1', 'stuck-2', 'stuck-3']
const result = await processOutboxEvents({})
expect(result.reaped).toBe(3)
expect(result.processed).toBe(0)
// The reaper's UPDATE sets status='pending' with NO attempts / availableAt
// fields — that's how runHandler's retry update is distinguished from it.
const reaperUpdate = state.updates.find(
(u) => u.set.status === 'pending' && !('attempts' in u.set) && !('availableAt' in u.set)
)
expect(reaperUpdate).toBeDefined()
expect(reaperUpdate?.set.lockedAt).toBeNull()
})
it('returns zero reaped when no rows are stuck', async () => {
const result = await processOutboxEvents({})
expect(result.reaped).toBe(0)
})
})
+366
View File
@@ -0,0 +1,366 @@
import { db } from '@sim/db'
import { outboxEvent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, lte } from 'drizzle-orm'
import { generateId } from '@/lib/core/utils/uuid'
const logger = createLogger('OutboxService')
const DEFAULT_MAX_ATTEMPTS = 10
const STUCK_PROCESSING_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes
const MAX_BACKOFF_MS = 60 * 60 * 1000 // 1 hour
const BASE_BACKOFF_MS = 1000 // 1 second, doubled per attempt
// Kept below the serverless route `maxDuration` (120s) so our in-process
// timeout fires before the platform kills the invocation and leaves the
// row stranded in `processing` for the 10-minute reaper window. Also well
// under `STUCK_PROCESSING_THRESHOLD_MS` so the reaper cannot steal a row
// a worker is still actively processing.
const DEFAULT_HANDLER_TIMEOUT_MS = 90 * 1000 // 90 seconds
/**
* Context passed to every outbox handler. Use `eventId` as the Stripe
* (or any external service) idempotency key so that handler retries
* collapse on the external side: a second execution of the same event
* lands on the same Stripe invoice id / charge id rather than creating
* a duplicate. The outbox lease CAS handles our DB side.
*/
export interface OutboxEventContext {
eventId: string
eventType: string
/** How many times this event has been attempted (zero on first run). */
attempts: number
}
/**
* A handler invoked by the outbox worker for events of a given type.
* Throwing bumps `attempts` and schedules a retry via exponential
* backoff; a successful return transitions the event to `completed`.
*/
export type OutboxHandler<T = unknown> = (payload: T, context: OutboxEventContext) => Promise<void>
/**
* Map of `eventType` handler. Register all handlers in one place
* and pass them to `processOutboxEvents`.
*/
export type OutboxHandlerRegistry = Record<string, OutboxHandler>
export interface EnqueueOptions {
/** Total attempts before the event moves to `dead_letter`. Default 10. */
maxAttempts?: number
/** Earliest time a worker may pick up this event. Default now. */
availableAt?: Date
}
export interface ProcessOutboxResult {
processed: number
retried: number
deadLettered: number
leaseLost: number
reaped: number
}
/**
* Transactional outbox for reliable "DB write + external system" flows.
*
* Callers enqueue an event *inside* a `db.transaction` alongside the
* primary write; the event row commits or rolls back with the business
* data. A polling worker (invoked via the cron endpoint) claims pending
* rows with `SELECT ... FOR UPDATE SKIP LOCKED`, marks them as
* `processing`, runs the registered handler outside the transaction,
* and transitions the event to `completed` / `pending` (retry) /
* `dead_letter` (max attempts exceeded).
*
* Two-phase claim-then-process keeps external API calls out of DB
* transactions. A reaper at the top of each run reclaims `processing`
* rows whose worker died mid-operation (stale `lockedAt`).
*
* Enqueue must be called with a `tx` from `db.transaction` so atomicity
* with the primary write is preserved. `db` itself is also accepted but
* then the caller must guarantee the enqueue and the primary write share
* a transaction some other way (or none at all).
*/
export async function enqueueOutboxEvent<T>(
executor: Pick<typeof db, 'insert'>,
eventType: string,
payload: T,
options: EnqueueOptions = {}
): Promise<string> {
const id = generateId()
await executor.insert(outboxEvent).values({
id,
eventType,
payload: payload as never,
maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
availableAt: options.availableAt ?? new Date(),
})
logger.info('Enqueued outbox event', { id, eventType })
return id
}
/**
* Process one batch of outbox events. Safe to call concurrently from
* multiple workers `SELECT FOR UPDATE SKIP LOCKED` serializes claims.
*/
export async function processOutboxEvents(
handlers: OutboxHandlerRegistry,
options: { batchSize?: number } = {}
): Promise<ProcessOutboxResult> {
const batchSize = options.batchSize ?? 10
const reaped = await reapStuckProcessingRows()
const claimed = await claimBatch(batchSize)
if (claimed.length === 0) {
return { processed: 0, retried: 0, deadLettered: 0, leaseLost: 0, reaped }
}
let processed = 0
let retried = 0
let deadLettered = 0
let leaseLost = 0
for (const event of claimed) {
const result = await runHandler(event, handlers)
if (result === 'completed') processed++
else if (result === 'dead_letter') deadLettered++
else if (result === 'lease_lost') leaseLost++
else retried++
}
return { processed, retried, deadLettered, leaseLost, reaped }
}
/**
* Reaper: move `processing` rows whose worker died (stale `lockedAt`)
* back to `pending` so another worker can pick them up. Without this,
* a SIGKILL between claim and result-write would permanently strand
* the row in `processing`.
*/
async function reapStuckProcessingRows(): Promise<number> {
const stuckBefore = new Date(Date.now() - STUCK_PROCESSING_THRESHOLD_MS)
const result = await db
.update(outboxEvent)
.set({ status: 'pending', lockedAt: null })
.where(and(eq(outboxEvent.status, 'processing'), lte(outboxEvent.lockedAt, stuckBefore)))
.returning({ id: outboxEvent.id })
if (result.length > 0) {
logger.warn('Reaped stuck outbox processing rows', {
count: result.length,
thresholdMs: STUCK_PROCESSING_THRESHOLD_MS,
})
}
return result.length
}
/**
* Phase 1: claim a batch of due pending events.
*
* `SELECT ... FOR UPDATE SKIP LOCKED` atomically picks rows that no
* other worker is currently looking at. We then flip those rows to
* `processing` inside the same tx so the claim survives the lock
* release the status change becomes the out-of-band mutual exclusion.
*/
async function claimBatch(batchSize: number): Promise<(typeof outboxEvent.$inferSelect)[]> {
const now = new Date()
return db.transaction(async (tx) => {
const rows = await tx
.select()
.from(outboxEvent)
.where(and(eq(outboxEvent.status, 'pending'), lte(outboxEvent.availableAt, now)))
.orderBy(asc(outboxEvent.createdAt))
.limit(batchSize)
.for('update', { skipLocked: true })
if (rows.length === 0) return []
await tx
.update(outboxEvent)
.set({ status: 'processing', lockedAt: now })
.where(
inArray(
outboxEvent.id,
rows.map((r) => r.id)
)
)
// Return rows with the claim state we just committed. `lockedAt`
// on this object is the authoritative lease timestamp used by the
// terminal-update lease CAS (see `runHandler`).
return rows.map((row) => ({
...row,
status: 'processing' as const,
lockedAt: now,
}))
})
}
/**
* Phase 2: invoke the handler for a claimed event, outside any DB
* transaction, then transition the row to its terminal or retry state.
*
* Every terminal UPDATE is guarded by a lease CAS (`WHERE status =
* 'processing' AND locked_at = event.lockedAt`). This defends against
* the "slow handler + reaper" race: if our handler takes longer than
* `STUCK_PROCESSING_THRESHOLD_MS`, the reaper will have reset the row
* to `pending` and another worker may have reclaimed it with a fresh
* `locked_at`. Our stale terminal write's WHERE clause won't match
* rowCount is 0 and we log+skip instead of clobbering the new lease.
*/
async function runHandler(
event: typeof outboxEvent.$inferSelect,
handlers: OutboxHandlerRegistry
): Promise<'completed' | 'pending' | 'dead_letter' | 'lease_lost'> {
const handler = handlers[event.eventType]
if (!handler) {
logger.error('No handler registered for outbox event type', {
eventId: event.id,
eventType: event.eventType,
})
await updateIfLeaseHeld(event, {
status: 'dead_letter',
lastError: `No handler registered for event type '${event.eventType}'`,
processedAt: new Date(),
lockedAt: null,
})
return 'dead_letter'
}
try {
await runHandlerWithTimeout(handler, event)
const updated = await updateIfLeaseHeld(event, {
status: 'completed',
processedAt: new Date(),
lockedAt: null,
})
if (!updated) {
logger.warn('Outbox event completion skipped — lease lost (reaped + reclaimed)', {
eventId: event.id,
eventType: event.eventType,
})
return 'lease_lost'
}
logger.info('Outbox event processed', {
eventId: event.id,
eventType: event.eventType,
attempts: event.attempts + 1,
})
return 'completed'
} catch (error) {
const nextAttempts = event.attempts + 1
const isDead = nextAttempts >= event.maxAttempts
const errMsg = error instanceof Error ? error.message : String(error)
if (isDead) {
const updated = await updateIfLeaseHeld(event, {
attempts: nextAttempts,
status: 'dead_letter',
lastError: errMsg,
processedAt: new Date(),
lockedAt: null,
})
if (!updated) {
logger.warn('Outbox event dead-letter skipped — lease lost', {
eventId: event.id,
eventType: event.eventType,
})
return 'lease_lost'
}
logger.error('Outbox event dead-lettered after max attempts', {
eventId: event.id,
eventType: event.eventType,
attempts: nextAttempts,
error: errMsg,
})
return 'dead_letter'
}
// Exponential backoff, capped at MAX_BACKOFF_MS.
const backoffMs = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** nextAttempts)
const nextAvailableAt = new Date(Date.now() + backoffMs)
const updated = await updateIfLeaseHeld(event, {
attempts: nextAttempts,
status: 'pending',
lastError: errMsg,
availableAt: nextAvailableAt,
lockedAt: null,
})
if (!updated) {
logger.warn('Outbox event retry-schedule skipped — lease lost', {
eventId: event.id,
eventType: event.eventType,
})
return 'lease_lost'
}
logger.warn('Outbox event failed, scheduled retry', {
eventId: event.id,
eventType: event.eventType,
attempts: nextAttempts,
backoffMs,
nextAvailableAt: nextAvailableAt.toISOString(),
error: errMsg,
})
return 'pending'
}
}
function runHandlerWithTimeout(
handler: OutboxHandler,
event: typeof outboxEvent.$inferSelect,
timeoutMs: number = DEFAULT_HANDLER_TIMEOUT_MS
): Promise<void> {
const context: OutboxEventContext = {
eventId: event.id,
eventType: event.eventType,
attempts: event.attempts,
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Outbox handler timed out after ${timeoutMs}ms`))
}, timeoutMs)
handler(event.payload, context)
.then((value) => {
clearTimeout(timeout)
resolve(value)
})
.catch((err) => {
clearTimeout(timeout)
reject(err)
})
})
}
/**
* Conditional terminal update scoped to the lease acquired at claim
* time. Returns true if the UPDATE affected a row, false if the row's
* lease was revoked (reaped, reclaimed by another worker). Callers
* treat `false` as a "lease lost" signal and skip without retrying
* the newer owner is responsible for the row now.
*/
async function updateIfLeaseHeld(
event: typeof outboxEvent.$inferSelect,
patch: {
status: 'completed' | 'pending' | 'dead_letter'
attempts?: number
lastError?: string | null
availableAt?: Date
lockedAt: Date | null
processedAt?: Date | null
}
): Promise<boolean> {
const whereClauses = [eq(outboxEvent.id, event.id), eq(outboxEvent.status, 'processing')]
if (event.lockedAt) {
whereClauses.push(eq(outboxEvent.lockedAt, event.lockedAt))
}
const result = await db
.update(outboxEvent)
.set(patch)
.where(and(...whereClauses))
.returning({ id: outboxEvent.id })
return result.length > 0
}
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { toError } from '@/lib/core/utils/helpers'
import { createStorageAdapter, type RateLimitStorageAdapter } from './storage'
import {
@@ -42,7 +42,7 @@ export class RateLimiter {
private getRateLimitKey(userId: string, subscription: SubscriptionInfo | null): string {
if (!subscription) return userId
if (isOrgPlan(subscription.plan) && subscription.referenceId !== userId) {
if (isOrgScopedSubscription(subscription, userId)) {
return subscription.referenceId
}
+4 -3
View File
@@ -16,7 +16,6 @@ import {
maybeSendUsageThresholdEmail,
} from '@/lib/billing/core/usage'
import { type ModelUsageMetadata, recordUsage } from '@/lib/billing/core/usage-log'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { redactApiKeys } from '@/lib/core/security/redaction'
@@ -415,9 +414,11 @@ export class ExecutionLogger implements IExecutionLoggerService {
const costDelta = costSummary.totalCost
const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers')
const { isOrgScopedSubscription } = await import('@/lib/billing/subscriptions/utils')
const planName = getDisplayPlanName(sub?.plan)
const scope: 'user' | 'organization' =
sub && isOrgPlan(sub.plan) ? 'organization' : 'user'
const scope: 'user' | 'organization' = isOrgScopedSubscription(sub, usr.id)
? 'organization'
: 'user'
if (scope === 'user') {
const before = await checkUsageStatus(usr.id)
@@ -0,0 +1,17 @@
CREATE TABLE "outbox_event" (
"id" text PRIMARY KEY NOT NULL,
"event_type" text NOT NULL,
"payload" json NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"max_attempts" integer DEFAULT 10 NOT NULL,
"available_at" timestamp DEFAULT now() NOT NULL,
"locked_at" timestamp,
"last_error" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"processed_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "user_stats" ADD COLUMN "pro_period_cost_snapshot_at" timestamp;--> statement-breakpoint
CREATE INDEX "outbox_event_status_available_idx" ON "outbox_event" USING btree ("status","available_at");--> statement-breakpoint
CREATE INDEX "outbox_event_locked_at_idx" ON "outbox_event" USING btree ("locked_at");
File diff suppressed because it is too large Load Diff
@@ -1331,6 +1331,13 @@
"when": 1776114737326,
"tag": "0190_shocking_karma",
"breakpoints": true
},
{
"idx": 191,
"version": "7",
"when": 1776502306122,
"tag": "0191_unusual_mongu",
"breakpoints": true
}
]
}
+25
View File
@@ -768,6 +768,7 @@ export const userStats = pgTable('user_stats', {
billedOverageThisPeriod: decimal('billed_overage_this_period').notNull().default('0'), // Amount of overage already billed via threshold billing
// Pro usage snapshot when joining a team (to prevent double-billing)
proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team
proPeriodCostSnapshotAt: timestamp('pro_period_cost_snapshot_at'), // When the snapshot was captured (= join moment). Used to cap daily-refresh computation so post-join refresh isn't deducted from pre-join personal Pro usage (and vice-versa for the org's pooled refresh).
// Pre-purchased credits (for Pro users only)
creditBalance: decimal('credit_balance').notNull().default('0'),
// Copilot usage tracking
@@ -1971,6 +1972,30 @@ export const idempotencyKey = pgTable(
})
)
export const outboxEvent = pgTable(
'outbox_event',
{
id: text('id').primaryKey(),
eventType: text('event_type').notNull(),
payload: json('payload').notNull(),
status: text('status').notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
maxAttempts: integer('max_attempts').notNull().default(10),
availableAt: timestamp('available_at').notNull().defaultNow(),
lockedAt: timestamp('locked_at'),
lastError: text('last_error'),
createdAt: timestamp('created_at').notNull().defaultNow(),
processedAt: timestamp('processed_at'),
},
(table) => ({
statusAvailableIdx: index('outbox_event_status_available_idx').on(
table.status,
table.availableAt
),
lockedAtIdx: index('outbox_event_locked_at_idx').on(table.lockedAt),
})
)
export const mcpServers = pgTable(
'mcp_servers',
{