kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: stripe checkout improvements
rodzic
b71ce684c0
commit
e598187b08
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
) : (
|
||||
|
|
Ładowanie…
Reference in New Issue