chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { purchaseCreditsContract } from '@/lib/api/contracts/subscription'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getCreditBalance } from '@/lib/billing/credits/balance'
|
||||
import { purchaseCredits } from '@/lib/billing/credits/purchase'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CreditsAPI')
|
||||
|
||||
export const GET = withRouteHandler(async () => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { balance, entityType, entityId } = await getCreditBalance(session.user.id)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { balance, entityType, entityId },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get credit balance', { error, userId: session.user.id })
|
||||
return NextResponse.json({ error: 'Failed to get credit balance' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(
|
||||
purchaseCreditsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: () =>
|
||||
NextResponse.json(
|
||||
{ error: 'Invalid amount. Must be between $10 and $1000' },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const result = await purchaseCredits({
|
||||
userId: session.user.id,
|
||||
amountDollars: parsed.data.body.amount,
|
||||
requestId: parsed.data.body.requestId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
action: AuditAction.CREDIT_PURCHASED,
|
||||
resourceType: AuditResourceType.BILLING,
|
||||
resourceId: parsed.data.body.requestId,
|
||||
description: `Purchased $${parsed.data.body.amount} in credits`,
|
||||
metadata: {
|
||||
amountDollars: parsed.data.body.amount,
|
||||
requestId: parsed.data.body.requestId,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to purchase credits', { error, userId: session.user.id })
|
||||
return NextResponse.json({ error: 'Failed to purchase credits' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSession, mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetStripeClient: vi.fn(),
|
||||
mockStripeInvoicesList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/stripe-client', () => ({
|
||||
getStripeClient: mockGetStripeClient,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/billing/invoices/route'
|
||||
|
||||
function makeInvoice(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `in_${generateShortId()}`,
|
||||
number: 'INV-1',
|
||||
created: 1700000000,
|
||||
total: 1000,
|
||||
amount_paid: 1000,
|
||||
currency: 'usd',
|
||||
status: 'paid',
|
||||
hosted_invoice_url: 'https://stripe.test/invoice',
|
||||
invoice_pdf: 'https://stripe.test/invoice.pdf',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/billing/invoices', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
dbChainMockFns.limit.mockResolvedValue([{ customer: 'cus_1' }])
|
||||
mockGetStripeClient.mockReturnValue({ invoices: { list: mockStripeInvoicesList } })
|
||||
})
|
||||
|
||||
it('does not surface hasMore when the trailing raw invoice beyond MAX_INVOICES is a draft', async () => {
|
||||
const finalized = Array.from({ length: 10 }, () => makeInvoice())
|
||||
mockStripeInvoicesList.mockResolvedValueOnce({
|
||||
data: [...finalized, makeInvoice({ status: 'draft' })],
|
||||
has_more: false,
|
||||
})
|
||||
|
||||
const request = createMockRequest('GET')
|
||||
const response = await GET(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(body.invoices).toHaveLength(10)
|
||||
expect(body.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('reports hasMore when there are genuinely more finalized invoices', async () => {
|
||||
const finalized = Array.from({ length: 11 }, () => makeInvoice())
|
||||
mockStripeInvoicesList.mockResolvedValueOnce({ data: finalized, has_more: false })
|
||||
|
||||
const request = createMockRequest('GET')
|
||||
const response = await GET(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(body.invoices).toHaveLength(10)
|
||||
expect(body.hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('pages through further drafts to confirm hasMore when the first page is inconclusive', async () => {
|
||||
const firstPage = Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' }))
|
||||
mockStripeInvoicesList
|
||||
.mockResolvedValueOnce({ data: firstPage, has_more: true })
|
||||
.mockResolvedValueOnce({ data: [makeInvoice()], has_more: false })
|
||||
|
||||
const request = createMockRequest('GET')
|
||||
const response = await GET(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(mockStripeInvoicesList).toHaveBeenCalledTimes(2)
|
||||
expect(mockStripeInvoicesList).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ starting_after: firstPage.at(-1)?.id })
|
||||
)
|
||||
expect(body.invoices).toHaveLength(1)
|
||||
expect(body.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('reports hasMore when the MAX_STRIPE_PAGES safety cap is hit while Stripe still has more', async () => {
|
||||
mockStripeInvoicesList.mockResolvedValue({
|
||||
data: Array.from({ length: 11 }, () => makeInvoice({ status: 'draft' })),
|
||||
has_more: true,
|
||||
})
|
||||
|
||||
const request = createMockRequest('GET')
|
||||
const response = await GET(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(mockStripeInvoicesList).toHaveBeenCalledTimes(5)
|
||||
expect(body.invoices).toHaveLength(0)
|
||||
expect(body.hasMore).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import { getInvoicesContract } from '@/lib/api/contracts/subscription'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
||||
import { getStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('BillingInvoices')
|
||||
|
||||
/** Cap the number of invoices returned to the most recent statements. */
|
||||
const MAX_INVOICES = 10
|
||||
|
||||
/** Stripe page size when scanning for finalized invoices; also bounds the has-more probe. */
|
||||
const STRIPE_PAGE_SIZE = MAX_INVOICES + 1
|
||||
|
||||
/** Safety cap on pagination when a customer has many draft invoices interspersed. */
|
||||
const MAX_STRIPE_PAGES = 5
|
||||
|
||||
interface FinalizedInvoicesPage {
|
||||
invoices: Stripe.Invoice[]
|
||||
/**
|
||||
* Whether Stripe's raw cursor still had more records after the scan
|
||||
* stopped — either because `invoices.length` became conclusive, or the
|
||||
* `MAX_STRIPE_PAGES` safety cap was hit first. Callers must OR this into
|
||||
* `hasMore` so hitting the cap never silently hides a "View all" that a
|
||||
* customer with many consecutive drafts genuinely has.
|
||||
*/
|
||||
stripeHasMore: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages through a customer's Stripe invoices, keeping only finalized ones,
|
||||
* until either more than `MAX_INVOICES` have been collected or Stripe's list
|
||||
* is exhausted (bounded by `MAX_STRIPE_PAGES`).
|
||||
*
|
||||
* Stripe's raw pagination cursor (`has_more`) counts draft invoices, which
|
||||
* the caller filters out — so a single page can under-report finalized
|
||||
* invoices while `has_more` is still true. Paging until the finalized count
|
||||
* is conclusive is what lets the caller derive an accurate `hasMore` for the
|
||||
* "View all" affordance.
|
||||
*/
|
||||
async function collectFinalizedInvoices(
|
||||
stripe: Stripe,
|
||||
stripeCustomerId: string
|
||||
): Promise<FinalizedInvoicesPage> {
|
||||
const invoices: Stripe.Invoice[] = []
|
||||
let startingAfter: string | undefined
|
||||
let stripeHasMore = true
|
||||
|
||||
for (
|
||||
let page = 0;
|
||||
page < MAX_STRIPE_PAGES && stripeHasMore && invoices.length <= MAX_INVOICES;
|
||||
page++
|
||||
) {
|
||||
const result = await stripe.invoices.list({
|
||||
customer: stripeCustomerId,
|
||||
limit: STRIPE_PAGE_SIZE,
|
||||
starting_after: startingAfter,
|
||||
})
|
||||
|
||||
invoices.push(
|
||||
...result.data.filter((invoice) => invoice.id && invoice.status && invoice.status !== 'draft')
|
||||
)
|
||||
stripeHasMore = result.has_more
|
||||
startingAfter = result.data.at(-1)?.id
|
||||
}
|
||||
|
||||
return { invoices, stripeHasMore }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists finalized Stripe invoices for the caller's billing customer (personal
|
||||
* or organization-scoped). Returns an empty list when there is no Stripe
|
||||
* customer yet or when Stripe is not configured, so the UI can simply hide the
|
||||
* Invoices section instead of surfacing an error.
|
||||
*/
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getInvoicesContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { context, organizationId } = parsed.data.query
|
||||
|
||||
if (context === 'organization' && !organizationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'organizationId is required when context=organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let stripeCustomerId: string | null = null
|
||||
|
||||
if (context === 'organization') {
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId!)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Resolve the org's customer via the canonical resolver so we deterministically
|
||||
// pick the same subscription (most recent entitled, ordered) the rest of the
|
||||
// billing UI uses — a bare limit(1) here could select a stale row.
|
||||
const orgSubscription = await getOrganizationSubscription(organizationId!)
|
||||
stripeCustomerId = orgSubscription?.stripeCustomerId ?? null
|
||||
} else {
|
||||
const rows = await db
|
||||
.select({ customer: user.stripeCustomerId })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
|
||||
}
|
||||
|
||||
const stripe = getStripeClient()
|
||||
if (!stripeCustomerId || !stripe) {
|
||||
return NextResponse.json({ success: true, invoices: [], hasMore: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const finalized = await collectFinalizedInvoices(stripe, stripeCustomerId)
|
||||
const hasMore = finalized.invoices.length > MAX_INVOICES || finalized.stripeHasMore
|
||||
const invoices = finalized.invoices.slice(0, MAX_INVOICES).map((invoice) => ({
|
||||
id: invoice.id as string,
|
||||
number: invoice.number ?? null,
|
||||
created: invoice.created,
|
||||
total: invoice.total,
|
||||
amountPaid: invoice.amount_paid,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status ?? null,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
|
||||
invoicePdf: invoice.invoice_pdf ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ success: true, invoices, hasMore })
|
||||
} catch (error) {
|
||||
logger.error('Failed to list invoices', { error, userId: session.user.id, context })
|
||||
return NextResponse.json({ error: 'Failed to list invoices' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getMyMemberCreditsContract } from '@/lib/api/contracts/organization'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkOrgMemberUsageLimit } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
/**
|
||||
* GET /api/billing/member-credits?workspaceId=...
|
||||
*
|
||||
* Returns the caller's OWN per-member usage and cap inside the workspace's
|
||||
* organization, in DOLLARS (the DB unit) so the client's `formatCredits` does the
|
||||
* single dollars→credits conversion. Own-data only, so no admin gate (unlike the
|
||||
* org/member admin route). Reuses {@link checkOrgMemberUsageLimit}, which yields a
|
||||
* null limit — and the chip falls back to the plan-level view — whenever no
|
||||
* per-member cap applies: non-hosted, the workspace isn't org-owned, or no cap is
|
||||
* set for this member.
|
||||
*/
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getMyMemberCreditsContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { workspaceId } = parsed.data.query
|
||||
const { currentUsage, limit } = await checkOrgMemberUsageLimit(session.user.id, workspaceId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
usedDollars: currentUsage,
|
||||
limitDollars: limit,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { billingPortalBodySchema } from '@/lib/api/contracts/subscription'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('BillingPortal')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const parsedBody = billingPortalBodySchema.safeParse(body)
|
||||
if (!parsedBody.success) {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
const context = parsedBody.data.context
|
||||
const organizationId = parsedBody.data.organizationId
|
||||
const returnUrl = parsedBody.data.returnUrl || `${getBaseUrl()}/workspace?billing=updated`
|
||||
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
let stripeCustomerId: string | null = null
|
||||
|
||||
if (context === 'organization') {
|
||||
if (!organizationId) {
|
||||
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Canonical resolver: deterministically selects the most recent entitled
|
||||
// org subscription, matching the rest of the billing UI.
|
||||
const orgSubscription = await getOrganizationSubscription(organizationId)
|
||||
stripeCustomerId = orgSubscription?.stripeCustomerId ?? null
|
||||
} else {
|
||||
const rows = await db
|
||||
.select({ customer: user.stripeCustomerId })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
|
||||
}
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
logger.error('Stripe customer not found for portal session', {
|
||||
context,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Stripe customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const portal = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: returnUrl,
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: portal.url })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create billing portal session', { error })
|
||||
return NextResponse.json({ error: 'Failed to create billing portal session' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { db, dbReplica } from '@sim/db'
|
||||
import { member } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { billingQuerySchema } from '@/lib/api/contracts/subscription'
|
||||
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 { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('UnifiedBillingAPI')
|
||||
|
||||
/**
|
||||
* Unified Billing Endpoint
|
||||
*/
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const parsedQuery = billingQuerySchema.safeParse({
|
||||
context: searchParams.get('context') || undefined,
|
||||
id: searchParams.get('id') || undefined,
|
||||
includeOrg: searchParams.get('includeOrg') === 'true',
|
||||
})
|
||||
|
||||
if (!parsedQuery.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid context. Must be "user" or "organization"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { context, id: contextId, includeOrg } = parsedQuery.data
|
||||
|
||||
// For organization context, require contextId
|
||||
if (context === 'organization' && !contextId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization ID is required when context=organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let billingData
|
||||
|
||||
if (context === 'user') {
|
||||
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, dbReplica),
|
||||
getEffectiveBillingStatus(session.user.id),
|
||||
])
|
||||
billingData = billingResult
|
||||
|
||||
billingData = {
|
||||
...billingData,
|
||||
billingBlocked: billingStatus.billingBlocked,
|
||||
billingBlockedReason: billingStatus.billingBlockedReason,
|
||||
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
|
||||
}
|
||||
|
||||
// Optionally include organization membership and role
|
||||
if (includeOrg) {
|
||||
const userMembership = await db
|
||||
.select({
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.where(eq(member.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (userMembership.length > 0) {
|
||||
billingData = {
|
||||
...billingData,
|
||||
organization: {
|
||||
id: userMembership[0].organizationId,
|
||||
role: userMembership[0].role as 'owner' | 'admin' | 'member',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get user role in organization for permission checks first
|
||||
const memberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, contextId!), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberRecord.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied - not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get organization-specific billing
|
||||
const rawBillingData = await getOrganizationBillingData(contextId!, dbReplica)
|
||||
|
||||
if (!rawBillingData) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization not found or access denied' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
billingData = {
|
||||
organizationId: rawBillingData.organizationId,
|
||||
organizationName: rawBillingData.organizationName,
|
||||
subscriptionPlan: rawBillingData.subscriptionPlan,
|
||||
subscriptionStatus: rawBillingData.subscriptionStatus,
|
||||
totalSeats: rawBillingData.totalSeats,
|
||||
usedSeats: rawBillingData.usedSeats,
|
||||
seatsCount: rawBillingData.seatsCount,
|
||||
totalCurrentUsage: rawBillingData.totalCurrentUsage,
|
||||
totalUsageLimit: rawBillingData.totalUsageLimit,
|
||||
minimumBillingAmount: rawBillingData.minimumBillingAmount,
|
||||
averageUsagePerMember: rawBillingData.averageUsagePerMember,
|
||||
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
|
||||
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
|
||||
members: rawBillingData.members.map((m) => ({
|
||||
...m,
|
||||
joinedAt: m.joinedAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
|
||||
const userRole = memberRecord[0].role
|
||||
|
||||
// Get effective billing blocked status (includes org owner check)
|
||||
const billingStatus = await getEffectiveBillingStatus(session.user.id)
|
||||
|
||||
// Merge blocked flag into data for convenience
|
||||
billingData = {
|
||||
...billingData,
|
||||
billingBlocked: billingStatus.billingBlocked,
|
||||
billingBlockedReason: billingStatus.billingBlockedReason,
|
||||
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
context,
|
||||
data: billingData,
|
||||
userRole,
|
||||
billingBlocked: billingData.billingBlocked,
|
||||
billingBlockedReason: billingData.billingBlockedReason,
|
||||
blockedByOrgOwner: billingData.blockedByOrgOwner,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
context,
|
||||
data: billingData,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get billing data', {
|
||||
userId: session?.user?.id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { subscription as subscriptionTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
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 } 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/env-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('SwitchPlan')
|
||||
|
||||
/**
|
||||
* POST /api/billing/switch-plan
|
||||
*
|
||||
* Switches a subscription's tier and/or billing interval via direct Stripe API.
|
||||
* Covers: Pro <-> Max, monthly <-> annual, and team tier changes.
|
||||
* Uses proration -- no Billing Portal redirect.
|
||||
*
|
||||
* Body:
|
||||
* targetPlanName: string -- e.g. 'pro_6000', 'team_25000'
|
||||
* interval?: 'month' | 'year' -- if omitted, keeps the current interval
|
||||
*/
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(billingSwitchPlanContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { targetPlanName, interval } = parsed.data.body
|
||||
const userId = session.user.id
|
||||
|
||||
const sub = await getHighestPrioritySubscription(userId)
|
||||
if (!sub || !sub.stripeSubscriptionId) {
|
||||
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const billingStatus = await getEffectiveBillingStatus(userId)
|
||||
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) {
|
||||
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enterprise plan changes must be handled via support' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const targetPlan = getPlanByName(targetPlanName)
|
||||
if (!targetPlan) {
|
||||
return NextResponse.json({ error: 'Target plan not found' }, { status: 400 })
|
||||
}
|
||||
|
||||
const currentPlanType = getPlanType(sub.plan)
|
||||
const targetPlanType = getPlanType(targetPlanName)
|
||||
if (currentPlanType !== targetPlanType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot switch between individual and team plans via this endpoint' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
const stripe = requireStripeClient()
|
||||
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)
|
||||
|
||||
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
|
||||
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
|
||||
}
|
||||
|
||||
const subscriptionItem = stripeSubscription.items.data[0]
|
||||
if (!subscriptionItem) {
|
||||
return NextResponse.json({ error: 'No subscription item found in Stripe' }, { status: 500 })
|
||||
}
|
||||
|
||||
const currentInterval = subscriptionItem.price?.recurring?.interval
|
||||
const targetInterval = interval ?? currentInterval ?? 'month'
|
||||
|
||||
const targetPriceId =
|
||||
targetInterval === 'year' ? targetPlan.annualDiscountPriceId : targetPlan.priceId
|
||||
|
||||
if (!targetPriceId) {
|
||||
return NextResponse.json(
|
||||
{ error: `No ${targetInterval} price configured for plan ${targetPlanName}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const alreadyOnStripePrice = subscriptionItem.price?.id === targetPriceId
|
||||
const alreadyInDb = sub.plan === targetPlanName
|
||||
|
||||
if (alreadyOnStripePrice && alreadyInDb) {
|
||||
return NextResponse.json({ success: true, message: 'Already on this plan and interval' })
|
||||
}
|
||||
|
||||
logger.info('Switching subscription', {
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
stripeSubscriptionId: sub.stripeSubscriptionId,
|
||||
fromPlan: sub.plan,
|
||||
toPlan: targetPlanName,
|
||||
fromInterval: currentInterval,
|
||||
toInterval: targetInterval,
|
||||
targetPriceId,
|
||||
})
|
||||
|
||||
if (!alreadyOnStripePrice) {
|
||||
const currentQuantity = subscriptionItem.quantity ?? 1
|
||||
|
||||
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
|
||||
items: [
|
||||
{
|
||||
id: subscriptionItem.id,
|
||||
price: targetPriceId,
|
||||
quantity: currentQuantity,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'always_invoice',
|
||||
})
|
||||
}
|
||||
|
||||
if (!alreadyInDb) {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ plan: targetPlanName })
|
||||
.where(eq(subscriptionTable.id, sub.id))
|
||||
}
|
||||
|
||||
await writeBillingInterval(sub.id, targetInterval as 'month' | 'year')
|
||||
|
||||
logger.info('Subscription switched successfully', {
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
fromPlan: sub.plan,
|
||||
toPlan: targetPlanName,
|
||||
interval: targetInterval,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'subscription_changed',
|
||||
{ from_plan: sub.plan ?? 'unknown', to_plan: targetPlanName, interval: targetInterval },
|
||||
{ set: { plan: targetPlanName } }
|
||||
)
|
||||
|
||||
if (isOrgScopedSubscription(sub, userId)) {
|
||||
recordAudit({
|
||||
actorId: userId,
|
||||
action: AuditAction.ORG_PLAN_CONVERTED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: sub.referenceId,
|
||||
description: `Plan converted from ${sub.plan ?? 'unknown'} to ${targetPlanName}`,
|
||||
metadata: {
|
||||
organizationId: sub.referenceId,
|
||||
subscriptionId: sub.id,
|
||||
fromPlan: sub.plan,
|
||||
toPlan: targetPlanName,
|
||||
interval: targetInterval,
|
||||
},
|
||||
request,
|
||||
})
|
||||
captureServerEvent(userId, 'plan_converted', {
|
||||
organization_id: sub.referenceId,
|
||||
from_plan: sub.plan ?? 'unknown',
|
||||
to_plan: targetPlanName,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
|
||||
} catch (error) {
|
||||
logger.error('Failed to switch subscription', {
|
||||
userId: session?.user?.id,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to switch plan') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockCheckInternalApiKey,
|
||||
mockRecordUsage,
|
||||
mockRecordCumulativeUsage,
|
||||
mockCheckAndBillOverageThreshold,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCheckInternalApiKey: vi.fn(),
|
||||
mockRecordUsage: vi.fn(),
|
||||
mockRecordCumulativeUsage: vi.fn(),
|
||||
mockCheckAndBillOverageThreshold: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
vi.mock('@/lib/copilot/request/http', () => ({
|
||||
checkInternalApiKey: mockCheckInternalApiKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/otel', () => ({
|
||||
withIncomingGoSpan: (
|
||||
_headers: unknown,
|
||||
_span: unknown,
|
||||
_attrs: unknown,
|
||||
fn: (span: { setAttribute: () => void; setAttributes: () => void }) => unknown
|
||||
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
recordUsage: mockRecordUsage,
|
||||
recordCumulativeUsage: mockRecordCumulativeUsage,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/threshold-billing', () => ({
|
||||
checkAndBillOverageThreshold: mockCheckAndBillOverageThreshold,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
isBillingEnabled: true,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/billing/update-cost/route'
|
||||
|
||||
describe('POST /api/billing/update-cost — workspaceId attribution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockCheckInternalApiKey.mockReturnValue({ success: true })
|
||||
mockRecordUsage.mockResolvedValue(undefined)
|
||||
mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
|
||||
mockCheckAndBillOverageThreshold.mockResolvedValue(undefined)
|
||||
dbChainMockFns.limit.mockResolvedValue([{ id: 'ws-1' }])
|
||||
})
|
||||
|
||||
it('stamps workspaceId onto recorded usage when provided (no idempotency key)', async () => {
|
||||
const res = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ userId: 'user-1', cost: 0.5, model: 'gpt', source: 'mcp_copilot', workspaceId: 'ws-1' },
|
||||
{ 'x-api-key': 'internal' }
|
||||
)
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
|
||||
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('records cumulative cost via monotonic top-up when an idempotency key is present', async () => {
|
||||
const res = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
userId: 'user-1',
|
||||
cost: 0.4662453,
|
||||
model: 'claude-opus-4.8',
|
||||
source: 'workspace-chat',
|
||||
workspaceId: 'ws-1',
|
||||
idempotencyKey: 'msg-1-billing',
|
||||
inputTokens: 461371,
|
||||
outputTokens: 1686,
|
||||
},
|
||||
{ 'x-api-key': 'internal' }
|
||||
)
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockRecordUsage).not.toHaveBeenCalled()
|
||||
expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(1)
|
||||
expect(mockRecordCumulativeUsage.mock.calls[0][0]).toMatchObject({
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
source: 'workspace-chat',
|
||||
model: 'claude-opus-4.8',
|
||||
cost: 0.4662453,
|
||||
eventKey: 'update-cost:msg-1-billing',
|
||||
})
|
||||
expect(mockCheckAndBillOverageThreshold).toHaveBeenCalledWith('user-1')
|
||||
})
|
||||
|
||||
it('returns 409 and skips overage when the cumulative is not higher (duplicate flush)', async () => {
|
||||
mockRecordCumulativeUsage.mockResolvedValue({ billed: false, delta: 0, total: 0.4662453 })
|
||||
const res = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
userId: 'user-1',
|
||||
cost: 0.4662453,
|
||||
model: 'claude-opus-4.8',
|
||||
source: 'workspace-chat',
|
||||
workspaceId: 'ws-1',
|
||||
idempotencyKey: 'msg-1-billing',
|
||||
},
|
||||
{ 'x-api-key': 'internal' }
|
||||
)
|
||||
)
|
||||
expect(res.status).toBe(409)
|
||||
expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records unattributed when workspaceId is omitted (headless client)', async () => {
|
||||
const res = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ userId: 'user-1', cost: 0.5, model: 'gpt', source: 'copilot' },
|
||||
{ 'x-api-key': 'internal' }
|
||||
)
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
|
||||
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
|
||||
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
|
||||
userId: 'user-1',
|
||||
workspaceId: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('records unattributed when the workspace does not exist in this deployment (self-hosted client)', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValue([])
|
||||
const res = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
userId: 'user-1',
|
||||
cost: 0.5,
|
||||
model: 'claude-opus-4.8',
|
||||
source: 'workspace-chat',
|
||||
workspaceId: 'self-hosted-ws',
|
||||
idempotencyKey: 'msg-1-billing',
|
||||
},
|
||||
{ 'x-api-key': 'internal' }
|
||||
)
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(1)
|
||||
expect(mockRecordCumulativeUsage.mock.calls[0][0]).toMatchObject({
|
||||
userId: 'user-1',
|
||||
workspaceId: undefined,
|
||||
eventKey: 'update-cost:msg-1-billing',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { Span } from '@opentelemetry/api'
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { billingUpdateCostContract } from '@/lib/api/contracts/subscription'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { recordCumulativeUsage, recordUsage } from '@/lib/billing/core/usage-log'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { BillingRouteOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
|
||||
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
||||
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/request/http'
|
||||
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('BillingUpdateCostAPI')
|
||||
|
||||
/**
|
||||
* Resolves the request-supplied workspace to one that exists in this
|
||||
* deployment. Workspace attribution on the usage ledger is best-effort:
|
||||
* self-hosted and headless clients bill through this endpoint with workspace
|
||||
* IDs from their own databases, and `usage_log.workspace_id` carries an FK to
|
||||
* `workspace`, so stamping a foreign ID would fail the entire flush with an
|
||||
* FK violation and strand real cost in the caller's dead-letter queue.
|
||||
* Unknown workspaces are recorded unattributed instead — billing is keyed on
|
||||
* the user's billing entity and never depends on the workspace.
|
||||
*/
|
||||
async function resolveAttributableWorkspaceId(
|
||||
requestId: string,
|
||||
workspaceId: string | undefined
|
||||
): Promise<string | undefined> {
|
||||
if (!workspaceId) return undefined
|
||||
|
||||
const [row] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
if (row) return row.id
|
||||
|
||||
logger.warn(`[${requestId}] Workspace not found in this deployment; recording unattributed`, {
|
||||
workspaceId,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/billing/update-cost
|
||||
* Update user cost with a pre-calculated cost value (internal API key auth required)
|
||||
*
|
||||
* Parented under the Go-side `sim.update_cost` span via W3C traceparent
|
||||
* propagation. Every mothership request that bills should therefore show
|
||||
* the Go client span AND this Sim server span sharing one trace, with
|
||||
* the actual usage/overage work nested below.
|
||||
*/
|
||||
export const POST = withRouteHandler((req: NextRequest) =>
|
||||
withIncomingGoSpan(
|
||||
req.headers,
|
||||
TraceSpan.CopilotBillingUpdateCost,
|
||||
{
|
||||
[TraceAttr.HttpMethod]: 'POST',
|
||||
[TraceAttr.HttpRoute]: '/api/billing/update-cost',
|
||||
},
|
||||
async (span) => updateCostInner(req, span)
|
||||
)
|
||||
)
|
||||
|
||||
async function updateCostInner(req: NextRequest, span: Span): Promise<NextResponse> {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Update cost request started`)
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.BillingDisabled)
|
||||
span.setAttribute(TraceAttr.HttpStatusCode, 200)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Billing disabled, cost update skipped',
|
||||
data: {
|
||||
billingEnabled: false,
|
||||
processedAt: new Date().toISOString(),
|
||||
requestId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check authentication (internal API key)
|
||||
const authResult = checkInternalApiKey(req)
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Authentication failed: ${authResult.error}`)
|
||||
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.AuthFailed)
|
||||
span.setAttribute(TraceAttr.HttpStatusCode, 401)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication failed',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
billingUpdateCostContract,
|
||||
req,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request body`, {
|
||||
errors: error.issues,
|
||||
})
|
||||
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody)
|
||||
span.setAttribute(TraceAttr.HttpStatusCode, 400)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request body',
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
invalidJsonResponse: () => {
|
||||
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InvalidBody)
|
||||
span.setAttribute(TraceAttr.HttpStatusCode, 400)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Request body must be valid JSON' },
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { userId, cost, model, inputTokens, outputTokens, source, idempotencyKey, workspaceId } =
|
||||
parsed.data.body
|
||||
const isMcp = source === 'mcp_copilot'
|
||||
|
||||
span.setAttributes({
|
||||
[TraceAttr.UserId]: userId,
|
||||
[TraceAttr.GenAiRequestModel]: model,
|
||||
[TraceAttr.BillingSource]: source,
|
||||
[TraceAttr.BillingCostUsd]: cost,
|
||||
[TraceAttr.GenAiUsageInputTokens]: inputTokens,
|
||||
[TraceAttr.GenAiUsageOutputTokens]: outputTokens,
|
||||
[TraceAttr.BillingIsMcp]: isMcp,
|
||||
...(idempotencyKey ? { [TraceAttr.BillingIdempotencyKey]: idempotencyKey } : {}),
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
cost,
|
||||
model,
|
||||
source,
|
||||
})
|
||||
|
||||
const attributedWorkspaceId = await resolveAttributableWorkspaceId(requestId, workspaceId)
|
||||
|
||||
// Go sends the request's CUMULATIVE cost, possibly more than once (a
|
||||
// mid-loop provider-error flush, then the recovered terminal flush, plus
|
||||
// abort-race duplicates). Record it as a monotonic top-up: one ledger row
|
||||
// per request holds the MAX cumulative and we bill only the delta, so
|
||||
// partial + complete flushes converge to the true total exactly once — no
|
||||
// under-billing on recovery, no over-billing on duplicates. When there is
|
||||
// no idempotency key (shouldn't happen for real requests) we fall back to a
|
||||
// plain append so cost is never silently dropped.
|
||||
let billed = true
|
||||
if (idempotencyKey) {
|
||||
const result = await recordCumulativeUsage({
|
||||
userId,
|
||||
workspaceId: attributedWorkspaceId,
|
||||
source,
|
||||
model,
|
||||
cost,
|
||||
eventKey: `update-cost:${idempotencyKey}`,
|
||||
metadata: { inputTokens, outputTokens },
|
||||
})
|
||||
billed = result.billed
|
||||
logger.info(`[${requestId}] Cumulative cost top-up`, {
|
||||
userId,
|
||||
source,
|
||||
cumulativeCost: cost,
|
||||
billedDelta: result.delta,
|
||||
newTotal: result.total,
|
||||
billed: result.billed,
|
||||
})
|
||||
} else {
|
||||
await recordUsage({
|
||||
userId,
|
||||
workspaceId: attributedWorkspaceId,
|
||||
entries: [
|
||||
{
|
||||
category: 'model',
|
||||
source,
|
||||
description: model,
|
||||
cost,
|
||||
sourceReference: requestId,
|
||||
metadata: { inputTokens, outputTokens },
|
||||
},
|
||||
],
|
||||
})
|
||||
logger.info(`[${requestId}] Recorded usage (no idempotency key)`, {
|
||||
userId,
|
||||
addedCost: cost,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Same-or-lower cumulative than already recorded: nothing new to bill. Tell
|
||||
// the caller via 409 (its existing "duplicate" outcome) without re-running
|
||||
// overage billing.
|
||||
if (!billed) {
|
||||
logger.info(`[${requestId}] Duplicate/non-increasing cumulative cost; no new charge`, {
|
||||
idempotencyKey,
|
||||
userId,
|
||||
cost,
|
||||
})
|
||||
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.DuplicateIdempotencyKey)
|
||||
span.setAttribute(TraceAttr.HttpStatusCode, 409)
|
||||
span.setAttribute(TraceAttr.BillingDurationMs, duration)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Duplicate request: cumulative cost already recorded',
|
||||
requestId,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user has hit overage threshold and bill incrementally. Reads the
|
||||
// (now topped-up) ledger total and is idempotent against billedOverage, so
|
||||
// it is safe to run on every flush that records new cost.
|
||||
await checkAndBillOverageThreshold(userId)
|
||||
|
||||
logger.info(`[${requestId}] Cost update completed successfully`, {
|
||||
userId,
|
||||
duration,
|
||||
cost,
|
||||
})
|
||||
|
||||
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.Billed)
|
||||
span.setAttribute(TraceAttr.HttpStatusCode, 200)
|
||||
span.setAttribute(TraceAttr.BillingDurationMs, duration)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId,
|
||||
cost,
|
||||
processedAt: new Date().toISOString(),
|
||||
requestId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Surface the underlying Postgres failure (e.g. 23503 FK violation vs a
|
||||
// lock timeout) — Drizzle's "Failed query" wrapper alone cannot
|
||||
// distinguish them, which made the dead-workspace incident undiagnosable
|
||||
// from logs.
|
||||
const pgCode = getPostgresErrorCode(error)
|
||||
const pgConstraint = getPostgresConstraintName(error)
|
||||
logger.error(`[${requestId}] Cost update failed`, {
|
||||
error: toError(error).message,
|
||||
...(pgCode && { pgCode }),
|
||||
...(pgConstraint && { pgConstraint }),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
duration,
|
||||
})
|
||||
|
||||
// The cumulative top-up runs in a single transaction (and a plain append is
|
||||
// a single insert), so a failure here leaves nothing partially committed —
|
||||
// a retry re-evaluates the max idempotently. No claim to release.
|
||||
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InternalError)
|
||||
span.setAttribute(TraceAttr.HttpStatusCode, 500)
|
||||
span.setAttribute(TraceAttr.BillingDurationMs, duration)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
requestId,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user