mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
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:
committed by
GitHub
parent
28b4c4cc67
commit
c246f5c660
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
+23
-17
@@ -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
|
||||
}
|
||||
|
||||
+27
-44
@@ -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,
|
||||
|
||||
+20
-14
@@ -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', {
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (1–5 ms per claim/store) is invisible on webhook responses, and
|
||||
* volume is low enough (roughly one event per customer per billing
|
||||
* cycle) that DB storage scales comfortably.
|
||||
*
|
||||
* `retryFailures: true` means a thrown handler releases the claim so
|
||||
* Stripe's next retry runs from scratch — without it, one transient
|
||||
* failure would poison the key for the whole TTL window.
|
||||
*
|
||||
* TTL of 7 days is slightly longer than Stripe's 3-day retry horizon so
|
||||
* late retries still dedupe against completed work. Rows past their TTL
|
||||
* are handled two ways: `atomicallyClaimDb` reclaims stale rows inline
|
||||
* via `ON CONFLICT DO UPDATE WHERE created_at < expired_before` (so
|
||||
* correctness does not depend on cleanup running), and the external
|
||||
* cleanup cron (scheduled from the infra repo) hits
|
||||
* `/api/webhooks/cleanup/idempotency` to bound table size.
|
||||
*/
|
||||
export const stripeWebhookIdempotency = new IdempotencyService({
|
||||
namespace: 'stripe-webhook',
|
||||
ttlSeconds: 60 * 60 * 24 * 7,
|
||||
retryFailures: true,
|
||||
forceStorage: 'database',
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { idempotencyKey } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { eq, lt } from 'drizzle-orm'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { getStorageMethod, type StorageMethod } from '@/lib/core/storage'
|
||||
@@ -16,6 +16,20 @@ export interface IdempotencyConfig {
|
||||
namespace?: string
|
||||
/** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */
|
||||
retryFailures?: boolean
|
||||
/**
|
||||
* Force a specific storage backend regardless of the environment's
|
||||
* auto-detection. Use `'database'` for correctness-critical flows
|
||||
* (money, billing, compliance) where the claim + operation should
|
||||
* fate-share with the Postgres transaction — this closes the narrow
|
||||
* window where the operation commits to DB but `storeResult` to Redis
|
||||
* fails and the retry re-runs the operation. Latency cost is 1–5ms
|
||||
* per call, imperceptible on webhook code paths.
|
||||
*
|
||||
* Leave unset (or set `'redis'`) for latency-sensitive, high-volume
|
||||
* flows like app webhook triggers where the scale benefits of Redis
|
||||
* outweigh the narrow durability window.
|
||||
*/
|
||||
forceStorage?: StorageMethod
|
||||
}
|
||||
|
||||
export interface IdempotencyResult {
|
||||
@@ -50,11 +64,12 @@ const POLL_INTERVAL_MS = 1000
|
||||
* that need duplicate prevention.
|
||||
*
|
||||
* Storage is determined once based on configuration:
|
||||
* - If REDIS_URL is set → Redis
|
||||
* - If REDIS_URL is not set → PostgreSQL
|
||||
* - If `forceStorage` is set → that backend unconditionally
|
||||
* - Else if `REDIS_URL` is set → Redis
|
||||
* - Else → PostgreSQL
|
||||
*/
|
||||
export class IdempotencyService {
|
||||
private config: Required<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',
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user