feat: stripe checkout improvements

pull/715/head
Travis Fischer 2025-06-20 18:42:28 -05:00
rodzic b71ce684c0
commit e598187b08
4 zmienionych plików z 245 dodań i 130 usunięć

Wyświetl plik

@ -47,6 +47,12 @@ export async function upsertStripePricingResources({
'Deployment and project must match'
)
// Keep track of promises for Stripe resources that are created in parallel
// to avoid race conditions.
const stripeProductIdPromiseMap = new Map<string, Promise<string>>()
const stripeMeterIdPromiseMap = new Map<string, Promise<string>>()
const stripePriceIdPromiseMap = new Map<string, Promise<string>>()
const stripeConnectParams = project._stripeAccountId
? [
{
@ -68,28 +74,43 @@ export async function upsertStripePricingResources({
// Upsert the Stripe Product
if (!project._stripeProductIdMap[pricingPlanLineItemSlug]) {
const productParams: Stripe.ProductCreateParams = {
name: `${project.identifier} ${pricingPlanLineItemSlug}`,
type: 'service',
metadata: {
projectId: project.id,
if (stripeProductIdPromiseMap.has(pricingPlanLineItemSlug)) {
const stripeProductId = await stripeProductIdPromiseMap.get(
pricingPlanLineItemSlug
}
}
)!
if (pricingPlanLineItem.usageType === 'licensed') {
productParams.unit_label = pricingPlanLineItem.label
project._stripeProductIdMap[pricingPlanLineItemSlug] = stripeProductId
dirty = true
} else {
productParams.unit_label = pricingPlanLineItem.unitLabel
const productParams: Stripe.ProductCreateParams = {
name: `${project.identifier} ${pricingPlanLineItemSlug}`,
type: 'service',
metadata: {
projectId: project.id,
pricingPlanLineItemSlug
}
}
if (pricingPlanLineItem.usageType === 'licensed') {
productParams.unit_label = pricingPlanLineItem.label
} else {
productParams.unit_label = pricingPlanLineItem.unitLabel
}
const productP = stripe.products.create(
productParams,
...stripeConnectParams
)
stripeProductIdPromiseMap.set(
pricingPlanLineItemSlug,
productP.then((p) => p.id)
)
const product = await productP
project._stripeProductIdMap[pricingPlanLineItemSlug] = product.id
dirty = true
}
const product = await stripe.products.create(
productParams,
...stripeConnectParams
)
project._stripeProductIdMap[pricingPlanLineItemSlug] = product.id
dirty = true
}
assert(project._stripeProductIdMap[pricingPlanLineItemSlug])
@ -97,28 +118,45 @@ export async function upsertStripePricingResources({
if (pricingPlanLineItem.usageType === 'metered') {
// Upsert the Stripe Meter
if (!project._stripeMeterIdMap[pricingPlanLineItemSlug]) {
const stripeMeter = await stripe.billing.meters.create(
{
display_name: `${project.identifier} ${pricingPlanLineItem.label || pricingPlanLineItemSlug}`,
event_name: `meter-${project.id}-${pricingPlanLineItemSlug}`,
// TODO: This currently isn't taken into account for the slug, so if it
// changes across deployments, the meter will not be updated.
default_aggregation: {
formula: pricingPlanLineItem.defaultAggregation?.formula ?? 'sum'
},
customer_mapping: {
event_payload_key: 'stripe_customer_id',
type: 'by_id'
},
value_settings: {
event_payload_key: 'value'
}
},
...stripeConnectParams
)
if (stripeMeterIdPromiseMap.has(pricingPlanLineItemSlug)) {
const stripeMeterId = await stripeMeterIdPromiseMap.get(
pricingPlanLineItemSlug
)!
project._stripeMeterIdMap[pricingPlanLineItemSlug] = stripeMeter.id
dirty = true
project._stripeMeterIdMap[pricingPlanLineItemSlug] = stripeMeterId
dirty = true
} else {
const meterP = stripe.billing.meters.create(
{
display_name: `${project.identifier} ${pricingPlanLineItem.label || pricingPlanLineItemSlug}`,
event_name: `meter-${project.id}-${pricingPlanLineItemSlug}`,
// TODO: This currently isn't taken into account for the slug, so if it
// changes across deployments, the meter will not be updated.
default_aggregation: {
formula:
pricingPlanLineItem.defaultAggregation?.formula ?? 'sum'
},
customer_mapping: {
event_payload_key: 'stripe_customer_id',
type: 'by_id'
},
value_settings: {
event_payload_key: 'value'
}
},
...stripeConnectParams
)
stripeMeterIdPromiseMap.set(
pricingPlanLineItemSlug,
meterP.then((m) => m.id)
)
const stripeMeter = await meterP
project._stripeMeterIdMap[pricingPlanLineItemSlug] = stripeMeter.id
dirty = true
}
}
assert(project._stripeMeterIdMap[pricingPlanLineItemSlug])
@ -141,98 +179,116 @@ export async function upsertStripePricingResources({
// Upsert the Stripe Price
if (!project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice]) {
const interval = pricingPlan.interval ?? project.defaultPricingInterval
if (stripePriceIdPromiseMap.has(pricingPlanLineItemHashForStripePrice)) {
const stripePriceId = await stripePriceIdPromiseMap.get(
pricingPlanLineItemHashForStripePrice
)!
// (nickname is hidden from customers)
const nickname = [
'price',
project.id,
pricingPlanLineItemSlug,
getLabelForPricingInterval(interval)
]
.filter(Boolean)
.join('-')
const priceParams: Stripe.PriceCreateParams = {
product: project._stripeProductIdMap[pricingPlanLineItemSlug],
currency: project.pricingCurrency,
nickname,
recurring: {
interval,
// TODO: support this
interval_count: 1,
usage_type: pricingPlanLineItem.usageType,
meter: project._stripeMeterIdMap[pricingPlanLineItemSlug]
},
metadata: {
projectId: project.id,
pricingPlanLineItemSlug
}
}
if (pricingPlanLineItem.usageType === 'licensed') {
priceParams.unit_amount_decimal = pricingPlanLineItem.amount.toFixed(12)
project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice] =
stripePriceId
dirty = true
} else {
priceParams.billing_scheme = pricingPlanLineItem.billingScheme
const interval = pricingPlan.interval ?? project.defaultPricingInterval
if (pricingPlanLineItem.billingScheme === 'tiered') {
assert(
pricingPlanLineItem.tiers?.length,
400,
`Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes must have at least one tier.`
)
// (nickname is hidden from customers)
const nickname = [
'price',
project.id,
pricingPlanLineItemSlug,
getLabelForPricingInterval(interval)
]
.filter(Boolean)
.join('-')
priceParams.tiers_mode = pricingPlanLineItem.tiersMode
priceParams.tiers = pricingPlanLineItem.tiers.map((tierData) => {
const tier: Stripe.PriceCreateParams.Tier = {
up_to: tierData.upTo
}
const priceParams: Stripe.PriceCreateParams = {
product: project._stripeProductIdMap[pricingPlanLineItemSlug],
currency: project.pricingCurrency,
nickname,
recurring: {
interval,
if (tierData.unitAmount !== undefined) {
tier.unit_amount_decimal = tierData.unitAmount.toFixed(12)
}
// TODO: support this
interval_count: 1,
if (tierData.flatAmount !== undefined) {
tier.flat_amount_decimal = tierData.flatAmount.toFixed(12)
}
usage_type: pricingPlanLineItem.usageType,
return tier
})
} else {
assert(
pricingPlanLineItem.billingScheme === 'per_unit',
400,
`Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": invalid billing scheme.`
)
assert(
pricingPlanLineItem.unitAmount !== undefined,
400,
`Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": unitAmount is required for per_unit billing schemes.`
)
meter: project._stripeMeterIdMap[pricingPlanLineItemSlug]
},
metadata: {
projectId: project.id,
pricingPlanLineItemSlug
}
}
if (pricingPlanLineItem.usageType === 'licensed') {
priceParams.unit_amount_decimal =
pricingPlanLineItem.unitAmount.toFixed(12)
pricingPlanLineItem.amount.toFixed(12)
} else {
priceParams.billing_scheme = pricingPlanLineItem.billingScheme
if (pricingPlanLineItem.transformQuantity) {
priceParams.transform_quantity = {
divide_by: pricingPlanLineItem.transformQuantity.divideBy,
round: pricingPlanLineItem.transformQuantity.round
if (pricingPlanLineItem.billingScheme === 'tiered') {
assert(
pricingPlanLineItem.tiers?.length,
400,
`Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes must have at least one tier.`
)
priceParams.tiers_mode = pricingPlanLineItem.tiersMode
priceParams.tiers = pricingPlanLineItem.tiers.map((tierData) => {
const tier: Stripe.PriceCreateParams.Tier = {
up_to: tierData.upTo
}
if (tierData.unitAmount !== undefined) {
tier.unit_amount_decimal = tierData.unitAmount.toFixed(12)
}
if (tierData.flatAmount !== undefined) {
tier.flat_amount_decimal = tierData.flatAmount.toFixed(12)
}
return tier
})
} else {
assert(
pricingPlanLineItem.billingScheme === 'per_unit',
400,
`Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": invalid billing scheme.`
)
assert(
pricingPlanLineItem.unitAmount !== undefined,
400,
`Invalid pricing plan metric "${pricingPlanLineItemSlug}" for pricing plan "${pricingPlanSlug}": unitAmount is required for per_unit billing schemes.`
)
priceParams.unit_amount_decimal =
pricingPlanLineItem.unitAmount.toFixed(12)
if (pricingPlanLineItem.transformQuantity) {
priceParams.transform_quantity = {
divide_by: pricingPlanLineItem.transformQuantity.divideBy,
round: pricingPlanLineItem.transformQuantity.round
}
}
}
}
const stripePriceP = stripe.prices.create(
priceParams,
...stripeConnectParams
)
stripePriceIdPromiseMap.set(
pricingPlanLineItemHashForStripePrice,
stripePriceP.then((p) => p.id)
)
const stripePrice = await stripePriceP
project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice] =
stripePrice.id
dirty = true
}
const stripePrice = await stripe.prices.create(
priceParams,
...stripeConnectParams
)
project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice] =
stripePrice.id
dirty = true
}
assert(project._stripePriceIdMap[pricingPlanLineItemHashForStripePrice])
@ -250,7 +306,7 @@ export async function upsertStripePricingResources({
}
}
await pAll(upserts, { concurrency: 4 })
await pAll(upserts, { concurrency: 8 })
if (dirty) {
await db

Wyświetl plik

@ -1,7 +1,8 @@
'use client'
import { Loader2Icon } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useAuthenticatedAgentic } from '@/components/agentic-provider'
@ -17,6 +18,8 @@ export function AppConsumerIndex({ consumerId }: { consumerId: string }) {
const checkout = searchParams.get('checkout')
const plan = searchParams.get('plan')
const { fireConfetti } = useConfettiFireworks()
const [isLoadingStripeBillingPortal, setIsLoadingStripeBillingPortal] =
useState(false)
const {
data: consumer,
@ -68,10 +71,22 @@ export function AppConsumerIndex({ consumerId }: { consumerId: string }) {
return
}
const { url } = await ctx!.api.createConsumerBillingPortalSession({
consumerId: consumer.id
})
globalThis.open(url, '_blank')
let url: string | undefined
try {
setIsLoadingStripeBillingPortal(true)
const res = await ctx!.api.createConsumerBillingPortalSession({
consumerId: consumer.id
})
url = res.url
} catch (err) {
void toastError(err, { label: 'Error creating billing portal session' })
} finally {
setIsLoadingStripeBillingPortal(false)
}
if (url) {
globalThis.open(url, '_blank')
}
}, [ctx, consumer])
return (
@ -95,7 +110,16 @@ export function AppConsumerIndex({ consumerId }: { consumerId: string }) {
<pre className='max-w-lg'>{JSON.stringify(consumer, null, 2)}</pre>
</div>
<Button onClick={onManageSubscription}>Manage Subscription</Button>
<Button
onClick={onManageSubscription}
disabled={isLoadingStripeBillingPortal}
>
{isLoadingStripeBillingPortal && (
<Loader2Icon className='animate-spin mr-2' />
)}
<span>Manage Subscription</span>
</Button>
</>
)}
</section>

Wyświetl plik

@ -1,17 +1,33 @@
'use client'
import { useCallback } from 'react'
import { Loader2Icon } from 'lucide-react'
import { useCallback, useState } from 'react'
import { useAuthenticatedAgentic } from '@/components/agentic-provider'
import { AppConsumersList } from '@/components/app-consumers-list'
import { Button } from '@/components/ui/button'
import { toastError } from '@/lib/notifications'
export function AppConsumersIndex() {
const ctx = useAuthenticatedAgentic()
const [isLoadingStripeBillingPortal, setIsLoadingStripeBillingPortal] =
useState(false)
const onManageSubscriptions = useCallback(async () => {
const { url } = await ctx!.api.createBillingPortalSession()
globalThis.open(url, '_blank')
let url: string | undefined
try {
setIsLoadingStripeBillingPortal(true)
const res = await ctx!.api.createBillingPortalSession()
url = res.url
} catch (err) {
void toastError(err, { label: 'Error creating billing portal session' })
} finally {
setIsLoadingStripeBillingPortal(false)
}
if (url) {
globalThis.open(url, '_blank')
}
}, [ctx])
return (
@ -23,7 +39,13 @@ export function AppConsumersIndex() {
Subscriptions
</h1>
<Button onClick={onManageSubscriptions}>Manage your subscriptions</Button>
<Button onClick={onManageSubscriptions}>
{isLoadingStripeBillingPortal && (
<Loader2Icon className='animate-spin mr-2' />
)}
<span>Manage your subscriptions</span>
</Button>
<AppConsumersList />
</>

Wyświetl plik

@ -1,8 +1,9 @@
'use client'
import { assert, omit, sanitizeSearchParams } from '@agentic/platform-core'
import { Loader2Icon } from 'lucide-react'
import { redirect, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useAgentic } from '@/components/agentic-provider'
import { LoadingIndicator } from '@/components/loading-indicator'
@ -19,6 +20,8 @@ export function MarketplaceProjectIndex({
const searchParams = useSearchParams()
const checkout = searchParams.get('checkout')
const plan = searchParams.get('plan')
const [isLoadingStripeCheckoutForPlan, setIsLoadingStripeCheckoutForPlan] =
useState<string | null>(null)
// Load the public project
const {
@ -77,6 +80,7 @@ export function MarketplaceProjectIndex({
let checkoutSession: { url: string; id: string } | undefined
try {
setIsLoadingStripeCheckoutForPlan(pricingPlanSlug)
const res = await ctx!.api.createConsumerCheckoutSession({
deploymentId: lastPublishedDeploymentId!,
plan: pricingPlanSlug
@ -86,6 +90,8 @@ export function MarketplaceProjectIndex({
checkoutSession = res.checkoutSession
} catch (err) {
return toastError(err, { label: 'Error creating checkout session' })
} finally {
setIsLoadingStripeCheckoutForPlan(null)
}
redirect(checkoutSession.url)
@ -177,8 +183,15 @@ export function MarketplaceProjectIndex({
<Button
onClick={() => onSubscribe(plan.slug)}
// TODO: handle free plans correctly
disabled={consumer?.plan === plan.slug}
disabled={
consumer?.plan === plan.slug ||
!!isLoadingStripeCheckoutForPlan
}
>
{isLoadingStripeCheckoutForPlan === plan.slug && (
<Loader2Icon className='animate-spin mr-2' />
)}
{consumer?.plan === plan.slug ? (
<span>Currently subscribed to "{plan.name}"</span>
) : (