d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
234 lines
7.2 KiB
TypeScript
234 lines
7.2 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { organization, userStats } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { eq } from 'drizzle-orm'
|
|
import { getPlanPricing } from '@/lib/billing/core/billing'
|
|
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
|
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
|
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')
|
|
|
|
/**
|
|
* Sets usage limit to planBase + creditBalance.
|
|
* This ensures users can use their plan's included amount plus any prepaid credits.
|
|
*/
|
|
export async function setUsageLimitForCredits(
|
|
entityType: 'user' | 'organization',
|
|
entityId: string,
|
|
plan: string,
|
|
seats: number | null,
|
|
creditBalance: number
|
|
): Promise<void> {
|
|
try {
|
|
const { basePrice } = getPlanPricing(plan)
|
|
|
|
const seatCount = seats || 1
|
|
const planBase =
|
|
entityType === 'organization' ? Number(basePrice) * seatCount : Number(basePrice)
|
|
const creditBalanceNum = Number(creditBalance)
|
|
const newLimit = planBase + creditBalanceNum
|
|
|
|
if (entityType === 'organization') {
|
|
const orgRows = await db
|
|
.select({ orgUsageLimit: organization.orgUsageLimit })
|
|
.from(organization)
|
|
.where(eq(organization.id, entityId))
|
|
.limit(1)
|
|
|
|
const currentLimit = orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].orgUsageLimit)) : 0
|
|
|
|
if (newLimit > currentLimit) {
|
|
await db
|
|
.update(organization)
|
|
.set({ orgUsageLimit: newLimit.toString() })
|
|
.where(eq(organization.id, entityId))
|
|
|
|
logger.info('Set org usage limit to planBase + credits', {
|
|
organizationId: entityId,
|
|
plan,
|
|
seats,
|
|
planBase,
|
|
creditBalance,
|
|
previousLimit: currentLimit,
|
|
newLimit,
|
|
})
|
|
}
|
|
} else {
|
|
const userStatsRows = await db
|
|
.select({ currentUsageLimit: userStats.currentUsageLimit })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, entityId))
|
|
.limit(1)
|
|
|
|
const currentLimit =
|
|
userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentUsageLimit)) : 0
|
|
|
|
if (newLimit > currentLimit) {
|
|
await db
|
|
.update(userStats)
|
|
.set({ currentUsageLimit: newLimit.toString() })
|
|
.where(eq(userStats.userId, entityId))
|
|
|
|
logger.info('Set user usage limit to planBase + credits', {
|
|
userId: entityId,
|
|
plan,
|
|
planBase,
|
|
creditBalance,
|
|
previousLimit: currentLimit,
|
|
newLimit,
|
|
})
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to set usage limit for credits', { entityType, entityId, error })
|
|
}
|
|
}
|
|
|
|
export interface PurchaseCreditsParams {
|
|
userId: string
|
|
amountDollars: number
|
|
requestId: string
|
|
}
|
|
|
|
export interface PurchaseResult {
|
|
success: boolean
|
|
error?: string
|
|
}
|
|
|
|
export async function purchaseCredits(params: PurchaseCreditsParams): Promise<PurchaseResult> {
|
|
const { userId, amountDollars, requestId } = params
|
|
|
|
if (amountDollars < 10 || amountDollars > 1000) {
|
|
return { success: false, error: 'Amount must be between $10 and $1000' }
|
|
}
|
|
|
|
const canPurchase = await canPurchaseCredits(userId)
|
|
if (!canPurchase) {
|
|
return { success: false, error: 'Only Pro and Team users can purchase credits' }
|
|
}
|
|
|
|
const subscription = await getHighestPrioritySubscription(userId)
|
|
if (!subscription || !subscription.stripeSubscriptionId) {
|
|
return { success: false, error: 'No active subscription found' }
|
|
}
|
|
|
|
// Enterprise users must contact support
|
|
if (isEnterprise(subscription.plan)) {
|
|
return { success: false, error: 'Enterprise users must contact support to purchase credits' }
|
|
}
|
|
|
|
let entityType: 'user' | 'organization' = 'user'
|
|
let entityId = userId
|
|
|
|
// 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' }
|
|
}
|
|
entityType = 'organization'
|
|
entityId = subscription.referenceId
|
|
}
|
|
|
|
try {
|
|
const stripe = requireStripeClient()
|
|
|
|
const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId)
|
|
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,
|
|
error: 'No payment method on file. Please update your billing info.',
|
|
}
|
|
}
|
|
|
|
const amountCents = Math.round(amountDollars * 100)
|
|
const idempotencyKey = `credit-purchase:${requestId}`
|
|
|
|
const creditMetadata = {
|
|
type: 'credit_purchase',
|
|
entityType,
|
|
entityId,
|
|
amountDollars: amountDollars.toString(),
|
|
purchasedBy: userId,
|
|
}
|
|
|
|
// Create invoice
|
|
const invoice = await stripe.invoices.create(
|
|
{
|
|
customer: customerId,
|
|
collection_method: 'charge_automatically',
|
|
auto_advance: false,
|
|
description: `Credit purchase - $${amountDollars}`,
|
|
metadata: creditMetadata,
|
|
default_payment_method: defaultPaymentMethod,
|
|
},
|
|
{ idempotencyKey: `${idempotencyKey}-invoice` }
|
|
)
|
|
|
|
// Add line item
|
|
await stripe.invoiceItems.create(
|
|
{
|
|
customer: customerId,
|
|
invoice: invoice.id,
|
|
amount: amountCents,
|
|
currency: 'usd',
|
|
description: `Prepaid credits ($${amountDollars})`,
|
|
metadata: creditMetadata,
|
|
},
|
|
{ idempotencyKey: `${idempotencyKey}-item` }
|
|
)
|
|
|
|
// Finalize and pay
|
|
if (!invoice.id) {
|
|
return { success: false, error: 'Failed to create invoice' }
|
|
}
|
|
|
|
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 },
|
|
{ idempotencyKey: `${idempotencyKey}-pay` }
|
|
)
|
|
}
|
|
|
|
logger.info('Credit purchase invoice created and paid', {
|
|
invoiceId: invoice.id,
|
|
entityType,
|
|
entityId,
|
|
amountDollars,
|
|
purchasedBy: userId,
|
|
})
|
|
|
|
return { success: true }
|
|
} catch (error) {
|
|
logger.error('Failed to purchase credits', { error, userId, amountDollars })
|
|
const message = getErrorMessage(error, 'Failed to process payment')
|
|
return { success: false, error: message }
|
|
}
|
|
}
|