Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -521,8 +521,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
</div>
</div>
{!subscription.isEnterprise &&
marketplaceBilling.isResolved &&
!billedThroughMarketplace &&
!marketplaceBilling.isPending &&
(canExplorePlans && upgradeHref ? (
<ChipLink
href={upgradeHref}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export interface UpgradeState {
}
showUpgradePlans: boolean
billedThroughMarketplace: boolean
/** How this workspace is billed could not be read, so plans stay hidden until a retry succeeds. */
billingLookupFailed: boolean
isRetryingBillingLookup: boolean
retryBillingLookup: () => void
proTier: { credits: number; dollars: number; name: string }
maxTier: { credits: number; dollars: number; name: string }
isOnPro: boolean
Expand Down Expand Up @@ -201,8 +205,11 @@ export function useUpgradeState({
setIsAnnual,
subscription,
showUpgradePlans:
!subscription.isEnterprise && !marketplace.isPending && !marketplace.billedThroughMarketplace,
!subscription.isEnterprise && marketplace.isResolved && !marketplace.billedThroughMarketplace,
billedThroughMarketplace: marketplace.billedThroughMarketplace,
billingLookupFailed: !marketplace.isResolved && marketplace.isError,
isRetryingBillingLookup: marketplace.isRetrying,
retryBillingLookup: marketplace.retry,
proTier: PRO_TIER,
maxTier: MAX_TIER,
isOnPro,
Expand Down
20 changes: 16 additions & 4 deletions apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,15 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
return null
}

if (!canManageBilling || state.billedThroughMarketplace) {
const description = canManageBilling
? 'Your subscription is billed through AWS Marketplace, so plans can’t be changed here.'
: hostContext.hostOrganizationId
if (!canManageBilling || state.billedThroughMarketplace || state.billingLookupFailed) {
const description = !canManageBilling
? hostContext.hostOrganizationId
? `Plans for ${hostContext.workspace.name} are managed by its organization administrators. Contact an organization admin to change this workspace’s plan.`
: `Only the owner of ${hostContext.workspace.name} can change this workspace’s plan.`
: state.billedThroughMarketplace
? 'Your subscription is billed through AWS Marketplace, so plans can’t be changed here.'
: 'We couldn’t confirm how this workspace is billed.'
const canRetryLookup = canManageBilling && state.billingLookupFailed

return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
Expand All @@ -103,6 +106,15 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
<div className='flex max-w-md flex-col items-center gap-3 text-center'>
<h1 className='text-[var(--text-body)] text-lg'>Workspace plans unavailable</h1>
<p className='text-[var(--text-muted)] text-sm'>{description}</p>
{canRetryLookup && (
<Chip
variant='border'
disabled={state.isRetryingBillingLookup}
onClick={state.retryBillingLookup}
>
{state.isRetryingBillingLookup ? 'Retrying…' : 'Try again'}
</Chip>
)}
</div>
</div>
</div>
Expand Down
37 changes: 37 additions & 0 deletions apps/sim/background/falai-cost-settlement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import {
FALAI_COST_SETTLEMENT_TASK_ID,
type FalAICostSettlementPayload,
settleFalAICost,
} from '@/lib/tools/falai-settlement'

const logger = createLogger('FalAICostSettlementTask')

/**
* Settles a hosted fal generation billed on an estimate to fal's exact charge. A receipt fal
* has not recorded yet fails the attempt so the retries keep looking for about an hour; past
* that the estimate stands.
*/
export const falaiCostSettlementTask = task({
id: FALAI_COST_SETTLEMENT_TASK_ID,
retry: {
maxAttempts: 8,
factor: 2,
minTimeoutInMs: 60_000,
maxTimeoutInMs: 15 * 60_000,
},
queue: { concurrencyLimit: 5 },
run: async (payload: FalAICostSettlementPayload) => {
const outcome = await settleFalAICost(payload)
if (outcome === 'pending') {
throw new Error(`fal has not recorded the charge for request ${payload.requestId} yet`)
}
logger.info('fal cost settlement finished', {
requestId: payload.requestId,
endpointId: payload.endpointId,
outcome,
})
return { outcome }
},
})
74 changes: 74 additions & 0 deletions apps/sim/hooks/queries/aws-marketplace.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** @vitest-environment jsdom */

import { act } from 'react'
import {
apiClientRequestMock,
apiClientRequestMockFns,
} from '@sim/testing/mocks/api-client-request.mock'
import { authClientMock } from '@sim/testing/mocks/auth-client.mock'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/api/client/request', () => apiClientRequestMock)
vi.mock('@/lib/auth/client', () => authClientMock)

import { useAwsMarketplaceBilling } from '@/hooks/queries/aws-marketplace'

const mockRequestJson = apiClientRequestMockFns.mockRequestJson

let root: Root | null = null
let billing: ReturnType<typeof useAwsMarketplaceBilling> | undefined

function Probe() {
billing = useAwsMarketplaceBilling('org-1')
return null
}

function render() {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>
)
})
}

async function settled(assertion: () => void) {
await act(async () => {
await vi.waitFor(assertion, { interval: 1, timeout: 2_000 })
})
}

afterEach(() => {
act(() => root?.unmount())
root = null
billing = undefined
document.body.replaceChildren()
})

describe('useAwsMarketplaceBilling', () => {
it('stays unresolved when the license lookup fails, so plan changes stay gated', async () => {
mockRequestJson.mockRejectedValueOnce(new Error('lookup failed'))
render()

await settled(() => expect(billing?.isError).toBe(true))
expect(billing?.isResolved).toBe(false)
expect(billing?.billedThroughMarketplace).toBe(false)
})

it('resolves to not Marketplace-billed when the scope holds no license', async () => {
mockRequestJson.mockResolvedValueOnce({ licenses: [] })
render()

await settled(() => expect(billing?.isResolved).toBe(true))
expect(billing?.billedThroughMarketplace).toBe(false)
expect(billing?.isError).toBe(false)
})
})
14 changes: 12 additions & 2 deletions apps/sim/hooks/queries/aws-marketplace.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import type { ContractBodyInput } from '@/lib/api/contracts'
Expand Down Expand Up @@ -70,12 +71,21 @@ export function useSaveAwsMarketplaceClaimContact() {

/**
* Whether AWS Marketplace bills this scope: the organization, or with null the viewer's account.
* A failed read reports false, so a lookup error never blocks a customer who pays through Stripe.
* Until `isResolved`, how the scope is billed is unknown, and an unknown state gates plan changes
* the same as Marketplace billing does: a failed lookup must not open Stripe checkout to a
* customer AWS bills. `retry` reads it again.
*/
export function useAwsMarketplaceBilling(organizationId: string | null) {
const licenses = useAwsMarketplaceLicenses(organizationId)
const { refetch } = licenses
const retry = useCallback(() => {
void refetch()
}, [refetch])
return {
isPending: licenses.isPending,
isResolved: licenses.data !== undefined,
isError: licenses.isError,
isRetrying: licenses.isFetching,
retry,
billedThroughMarketplace:
licenses.data?.some((license) => license.status !== 'inactive') ?? false,
}
Expand Down
31 changes: 28 additions & 3 deletions apps/sim/lib/billing/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { APIError } from 'better-auth/api'
import { and, eq, isNotNull } from 'drizzle-orm'
import { getAwsMarketplaceProductCode } from '@/lib/billing/aws-marketplace/config'
import { holdsMarketplaceLicense } from '@/lib/billing/aws-marketplace/repository'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import {
getOrganizationCoverageForMember,
Expand Down Expand Up @@ -55,6 +57,22 @@ async function assertNoBoundIncompleteSubscription(referenceId: string): Promise
})
}

/**
* Refuse a Stripe checkout for a reference AWS Marketplace bills. A listed product must be billed
* entirely through AWS, and such a customer looks Free to Stripe, so a checkout would start a
* second, real subscription. The client hides plans too, but only this check holds when its
* license lookup fails.
*/
async function assertNotBilledThroughMarketplace(referenceId: string): Promise<void> {
if (!getAwsMarketplaceProductCode()) return
if (!(await holdsMarketplaceLicense(referenceId))) return

logger.warn('Blocking checkout - reference is billed through AWS Marketplace', { referenceId })
throw new APIError('CONFLICT', {
message: 'This subscription is billed through AWS Marketplace. Contact support to change it.',
})
}

/**
* Classify a `/subscription/upgrade` request as a personal checkout using
* the same reference resolution as the Better Auth Stripe plugin
Expand All @@ -81,9 +99,10 @@ export function isPersonalCheckoutRequest(
/**
* Guard for personal (user-referenced) checkouts on `/subscription/upgrade`.
*
* Blocks both a bound, incomplete Stripe subscription and a member already
* covered by an entitled organization subscription. Throws {@link APIError}
* with a user-facing message; the checkout UI surfaces it as-is.
* Blocks an account AWS Marketplace bills, a bound, incomplete Stripe
* subscription, and a member already covered by an entitled organization
* subscription. Throws {@link APIError} with a user-facing message; the
* checkout UI surfaces it as-is.
*
* Called from the Better Auth `hooks.before` middleware, NOT from
* `authorizeReference`: the Stripe plugin skips `authorizeReference`
Expand All @@ -94,6 +113,7 @@ export function isPersonalCheckoutRequest(
* rejected rather than risking a duplicate subscription.
*/
export async function assertPersonalCheckoutAllowed(userId: string): Promise<void> {
await assertNotBilledThroughMarketplace(userId)
await assertNoBoundIncompleteSubscription(userId)

const coverage = await getOrganizationCoverageForMember(userId)
Expand Down Expand Up @@ -130,6 +150,7 @@ export async function assertPersonalCheckoutAllowed(userId: string): Promise<voi
* For `upgrade-subscription` (checkout) this enforces, each thrown as an
* {@link APIError} with a descriptive message so callers see the real
* reason instead of a generic "Unauthorized":
* - An organization AWS Marketplace bills cannot check out through Stripe.
* - Organizations can only check out Team or Enterprise plans — a `pro_*`
* plan can never become org-referenced.
* - A bound, incomplete Stripe subscription must finish before another
Expand All @@ -148,6 +169,10 @@ export async function authorizeSubscriptionReference(
return true
}

if (action === 'upgrade-subscription') {
await assertNotBilledThroughMarketplace(referenceId)
}

if (action === 'upgrade-subscription' && !isOrgPlan(plan)) {
logger.warn('Blocking checkout - organizations can only subscribe to Team or Enterprise', {
userId,
Expand Down
Loading
Loading