feat: improve pricing core types

pull/715/head
Travis Fischer 2025-06-01 19:43:04 +07:00
rodzic 5849691936
commit 2656f75b1d
6 zmienionych plików z 523 dodań i 114 usunięć

Wyświetl plik

@ -100,12 +100,12 @@ export async function resolveOriginRequest(
if (requestsLineItem) {
assert(
requestsLineItem?.slug === 'requests',
requestsLineItem.slug === 'requests',
403,
`Invalid pricing plan "${pricingPlan.slug}" for project "${deployment.project}"`
)
rateLimit = requestsLineItem?.rateLimit
rateLimit = requestsLineItem.rateLimit
} else {
// No `requests` line-item, so we don't report usage for this tool.
reportUsage = false

Wyświetl plik

@ -0,0 +1,96 @@
import { expectTypeOf, test } from 'vitest'
import type { AgenticProjectConfigInput } from './agentic-project-config'
test('AgenticProjectConfig input types', () => {
expectTypeOf<{
name: 'test'
originUrl: 'https://httpbin.org'
}>().toExtend<AgenticProjectConfigInput>()
expectTypeOf<{
name: 'test'
originUrl: 'https://httpbin.org'
pricingPlans: [
{
name: 'Free'
slug: 'free'
lineItems: [
{
slug: 'base'
usageType: 'licensed'
amount: 0
}
]
}
]
}>().toExtend<AgenticProjectConfigInput>()
expectTypeOf<{
name: 'test'
originUrl: 'https://httpbin.org'
pricingPlans: [
{
name: 'Basic Monthly'
slug: 'basic-monthly'
lineItems: [
{
slug: 'requests'
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: 50
rateLimit: {
// Make sure `interval` can use a string as input
interval: '30s'
maxPerInterval: 100
}
}
]
}
]
}>().toExtend<AgenticProjectConfigInput>()
expectTypeOf<{
name: 'test'
originUrl: 'https://httpbin.org'
pricingPlans: [
{
name: 'Basic Monthly'
slug: 'basic-monthly'
lineItems: [
{
slug: 'requests'
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: 50
rateLimit: {
// Make sure `interval` can use a number as input
interval: 300
maxPerInterval: 100
}
}
]
}
]
}>().toExtend<AgenticProjectConfigInput>()
expectTypeOf<{
name: 'test'
originUrl: 'https://httpbin.org'
// Invalid because `pricingPlans` must be non-empty if defined
pricingPlans: []
}>().not.toExtend<AgenticProjectConfigInput>()
expectTypeOf<{
name: 'test'
originUrl: 'https://httpbin.org'
pricingPlans: [
{
name: 'Basic Monthly'
slug: 'basic-monthly'
// Invalid because `lineItems` must be non-empty
lineItems: []
}
]
}>().not.toExtend<AgenticProjectConfigInput>()
})

Wyświetl plik

@ -9,6 +9,7 @@ import {
defaultFreePricingPlan,
pricingIntervalListSchema,
type PricingPlanList,
type PricingPlanListInput,
pricingPlanListSchema
} from './pricing'
import { toolConfigSchema, toolSchema } from './tools'
@ -159,7 +160,7 @@ To add support for annual pricing plans, for example, you can use: \`['month', '
export type AgenticProjectConfigInput = Simplify<
Omit<z.input<typeof agenticProjectConfigSchema>, 'pricingPlans'> & {
pricingPlans?: PricingPlanList
pricingPlans?: PricingPlanListInput
}
>
export type AgenticProjectConfigRaw = z.output<

Wyświetl plik

@ -0,0 +1,225 @@
import { assert, expectTypeOf, test } from 'vitest'
import type {
CustomPricingPlanLineItemSlug,
PricingPlanLineItem,
PricingPlanList
} from './pricing'
test('PricingPlanLineItem "base" type', () => {
expectTypeOf({
slug: 'base',
usageType: 'licensed',
amount: 100
} as const).toExtend<PricingPlanLineItem>()
expectTypeOf<{
slug: 'base'
usageType: 'licensed'
amount: number
}>().toExtend<PricingPlanLineItem>()
})
test('PricingPlanLineItem "requests" per-unit type', () => {
expectTypeOf({
slug: 'requests',
usageType: 'metered',
billingScheme: 'per_unit',
unitAmount: 100
} as const).toExtend<PricingPlanLineItem>()
expectTypeOf<{
slug: 'requests'
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: number
}>().toExtend<PricingPlanLineItem>()
expectTypeOf({
slug: 'requests',
usageType: 'metered',
billingScheme: 'per_unit'
// invalid because `unitAmount` is required
} as const).not.toExtend<PricingPlanLineItem>()
expectTypeOf<{
slug: 'requests'
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount?: number // invalid because `unitAmount` is required
}>().not.toExtend<PricingPlanLineItem>()
})
test('PricingPlanLineItem "requests" tiered type', () => {
expectTypeOf<{
slug: 'requests'
usageType: 'metered'
billingScheme: 'tiered'
tiersMode: 'volume'
tiers: [
{
amount: 300
upTo: 1000
},
{
amount: 200
upTo: 2000
},
{
amount: 100
upTo: 'inf'
}
]
}>().toExtend<PricingPlanLineItem>()
})
test('PricingPlanLineItem "custom" licensed type', () => {
expectTypeOf({
slug: 'custom-licensed',
usageType: 'licensed',
amount: 100
} as const).toExtend<PricingPlanLineItem>()
expectTypeOf<{
slug: 'custom-licensed'
usageType: 'licensed'
amount: number
}>().toExtend<PricingPlanLineItem>()
})
test('PricingPlanLineItem "custom" metered per-unit type', () => {
expectTypeOf({
slug: 'custom-test',
usageType: 'metered',
billingScheme: 'per_unit',
unitAmount: 100
} as const).toExtend<PricingPlanLineItem>()
expectTypeOf<{
slug: 'custom-test'
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: number
}>().toExtend<PricingPlanLineItem>()
})
test('PricingPlanLineItem "custom" metered tiered type', () => {
expectTypeOf<{
slug: 'custom-test'
usageType: 'metered'
billingScheme: 'tiered'
tiersMode: 'volume'
tiers: [
{
amount: 300
upTo: 1000
},
{
amount: 200
upTo: 2000
},
{
amount: 100
upTo: 'inf'
}
]
}>().toExtend<PricingPlanLineItem>()
})
test('PricingPlanList type', () => {
// Empty array should be invalid
expectTypeOf<[]>().not.toExtend<PricingPlanList>()
// Empty lineItems should be invalid
expectTypeOf<
[
{
name: 'Free'
slug: 'free'
lineItems: []
}
]
>().not.toExtend<PricingPlanList>()
expectTypeOf<
[
{
name: 'Free'
slug: 'free'
lineItems: [
{
slug: 'base'
usageType: 'licensed'
amount: 0
}
]
}
]
>().toExtend<PricingPlanList>()
})
test('PricingPlanLineItem "base" type discrimination', () => {
const foo: PricingPlanLineItem = {
slug: 'base',
usageType: 'licensed',
amount: 100
}
expectTypeOf(foo).toExtend<PricingPlanLineItem>()
// These should fail if `slug` is not differentiating correctly.
expectTypeOf(foo).toExtend<{
slug: 'base'
usageType: 'licensed'
amount: number
label?: string
}>()
expectTypeOf<typeof foo>().toExtend<{
slug: 'base'
}>()
expectTypeOf<typeof foo>().toExtend<{
usageType: 'licensed'
}>()
})
test('PricingPlanLineItem "requests" per-unit type discrimination', () => {
const foo: PricingPlanLineItem = {
slug: 'requests',
usageType: 'metered',
billingScheme: 'per_unit',
unitAmount: 100
}
expectTypeOf(foo).toExtend<PricingPlanLineItem>()
// These should fail if `slug` is not differentiating correctly.
expectTypeOf<typeof foo>().toExtend<{
slug: 'requests'
}>()
expectTypeOf<typeof foo>().toExtend<{
usageType: 'metered'
}>()
expectTypeOf<typeof foo>().toExtend<{
billingScheme: 'per_unit'
}>()
expectTypeOf<typeof foo>().toExtend<{
unitAmount: number
}>()
})
test('PricingPlanLineItem "metered" type discrimination', () => {
const foo = {
usageType: 'metered'
} as PricingPlanLineItem
expectTypeOf(foo).toExtend<PricingPlanLineItem>()
assert(foo.usageType === 'metered')
// These should fail if `usageType` is not differentiating correctly.
expectTypeOf<typeof foo>().toExtend<{
slug: 'requests' | CustomPricingPlanLineItemSlug
}>()
expectTypeOf<typeof foo>().toExtend<{
billingScheme: 'per_unit' | 'tiered'
}>()
})

Wyświetl plik

@ -155,9 +155,6 @@ export const pricingPlanLicensedLineItemSchema =
amount: z.number().nonnegative()
})
)
export type PricingPlanLicensedLineItem = z.infer<
typeof pricingPlanLicensedLineItemSchema
>
/**
* Metered LineItems are used to charge for usage-based services.
@ -275,81 +272,6 @@ export const pricingPlanMeteredLineItemSchema =
.optional()
})
)
export type PricingPlanMeteredLineItem =
| {
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: number
label?: string
unitLabel?: string
rateLimit?: {
interval: number
maxPerInterval: number
}
defaultAggregation?: {
formula: 'sum' | 'count' | 'last'
}
transformQuantity?: {
divideBy: number
round: 'down' | 'up'
}
}
| {
usageType: 'metered'
billingScheme: 'tiered'
tiers: PricingPlanTier[]
tiersMode: 'graduated' | 'volume'
label?: string
unitLabel?: string
rateLimit?: {
interval: number
maxPerInterval: number
}
defaultAggregation?: {
formula: 'sum' | 'count' | 'last'
}
transformQuantity?: {
divideBy: number
round: 'down' | 'up'
}
}
/**
* The `base` LineItem is used to charge a fixed amount for a service using
* `licensed` usage type.
*/
export const basePricingPlanLineItemSchema =
pricingPlanLicensedLineItemSchema.extend({
slug: z.literal('base')
})
export type BasePricingPlanLineItem = z.infer<
typeof basePricingPlanLineItemSchema
>
/**
* The `requests` LineItem is used to charge for usage-based services using the
* `metered` usage type.
*
* It corresponds to the total number of API calls made by a customer during a
* given billing interval.
*/
export const requestsPricingPlanLineItemSchema =
pricingPlanMeteredLineItemSchema.extend({
slug: z.literal('requests'),
/**
* Optional label for the line-item which will be displayed on customer
* bills.
*
* If unset, the line-item's `slug` will be used as the unit label.
*/
unitLabel: z.string().default('API calls').optional()
})
export type RequestsPricingPlanLineItem = Simplify<
PricingPlanMeteredLineItem & {
slug: 'requests'
}
>
/**
* PricingPlanLineItems represent a single line-item in a Stripe Subscription.
@ -404,37 +326,146 @@ export const pricingPlanLineItemSchema = z
.openapi('PricingPlanLineItem')
// export type PricingPlanLineItem = z.infer<typeof pricingPlanLineItemSchema>
// This is a more complex discriminated union based on: `slug`, `usageType`,
// and `billingScheme`.
// TODO: clean up this type
// TODO: add `Input` version to support `string` rateLimit.interval
export type PricingPlanLineItem =
| BasePricingPlanLineItem
| RequestsPricingPlanLineItem
| ({
slug: CustomPricingPlanLineItemSlug
usageType: 'licensed'
} & Omit<PricingPlanLicensedLineItem, 'slug' | 'usageType'>)
| ({
slug: CustomPricingPlanLineItemSlug
usageType: 'metered'
billingScheme: 'per_unit'
} & Omit<
PricingPlanMeteredLineItem & {
// These are more complex discriminated unions based on: `slug`, `usageType`,
// and `billingScheme`. That's why we're not using zod's inference directly
// for these types. See `./pricing.test.ts` for examples.
export type PricingPlanLineItemInput =
// "base" licensed line-item
| Simplify<
{
slug: 'base'
} & z.input<typeof pricingPlanLicensedLineItemSchema>
>
// "custom" licensed line-item
| Simplify<
{
slug: CustomPricingPlanLineItemSlug
usageType: 'licensed'
} & z.input<typeof pricingPlanLicensedLineItemSchema>
>
// "requests" metered per-unit line-item
| Simplify<
{
slug: 'requests'
usageType: 'metered'
billingScheme: 'per_unit'
},
'slug' | 'usageType' | 'billingScheme' | 'tiers' | 'tiersMode'
>)
| ({
slug: CustomPricingPlanLineItemSlug
usageType: 'metered'
billingScheme: 'tiered'
} & Omit<
PricingPlanMeteredLineItem & {
unitAmount: number
} & Omit<
z.input<typeof pricingPlanMeteredLineItemSchema> & {
billingScheme: 'per_unit'
},
'tiers' | 'tiersMode'
>
>
// "requests" metered tiered line-item
| Simplify<
{
slug: 'requests'
usageType: 'metered'
billingScheme: 'tiered'
},
'slug' | 'usageType' | 'billingScheme' | 'unitAmount'
>)
} & Omit<
z.input<typeof pricingPlanMeteredLineItemSchema> & {
billingScheme: 'tiered'
},
'unitAmount' | 'transformQuantity'
>
>
// "custom" metered per-unit line-item
| Simplify<
{
slug: CustomPricingPlanLineItemSlug
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: number
} & Omit<
z.input<typeof pricingPlanMeteredLineItemSchema> & {
billingScheme: 'per_unit'
},
'tiers' | 'tiersMode'
>
>
// "custom" metered tiered line-item
| Simplify<
{
slug: CustomPricingPlanLineItemSlug
usageType: 'metered'
billingScheme: 'tiered'
} & Omit<
z.input<typeof pricingPlanMeteredLineItemSchema> & {
billingScheme: 'tiered'
},
'unitAmount' | 'transformQuantity'
>
>
export type PricingPlanLineItem =
// "base" licensed line-item
| Simplify<
{
slug: 'base'
} & z.infer<typeof pricingPlanLicensedLineItemSchema>
>
// "custom" licensed line-item
| Simplify<
{
slug: CustomPricingPlanLineItemSlug
usageType: 'licensed'
} & z.infer<typeof pricingPlanLicensedLineItemSchema>
>
// "requests" metered per-unit line-item
| Simplify<
{
slug: 'requests'
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: number
} & Omit<
z.infer<typeof pricingPlanMeteredLineItemSchema> & {
billingScheme: 'per_unit'
},
'tiers' | 'tiersMode'
>
>
// "requests" metered tiered line-item
| Simplify<
{
slug: 'requests'
usageType: 'metered'
billingScheme: 'tiered'
} & Omit<
z.infer<typeof pricingPlanMeteredLineItemSchema> & {
billingScheme: 'tiered'
},
'unitAmount' | 'transformQuantity'
>
>
// "custom" metered per-unit line-item
| Simplify<
{
slug: CustomPricingPlanLineItemSlug
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: number
} & Omit<
z.infer<typeof pricingPlanMeteredLineItemSchema> & {
billingScheme: 'per_unit'
},
'tiers' | 'tiersMode'
>
>
// "custom" metered tiered line-item
| Simplify<
{
slug: CustomPricingPlanLineItemSlug
usageType: 'metered'
billingScheme: 'tiered'
} & Omit<
z.infer<typeof pricingPlanMeteredLineItemSchema> & {
billingScheme: 'tiered'
},
'unitAmount' | 'transformQuantity'
>
>
/**
* Represents the config for a single Stripe subscription plan with one or more
@ -492,9 +523,16 @@ export const pricingPlanSchema = z
)
.openapi('PricingPlan')
// export type PricingPlan = z.infer<typeof pricingPlanSchema>
export type PricingPlanInput = Simplify<
Omit<z.input<typeof pricingPlanSchema>, 'lineItems'> & {
lineItems: [PricingPlanLineItemInput, ...PricingPlanLineItemInput[]]
}
>
export type PricingPlan = Simplify<
Omit<z.infer<typeof pricingPlanSchema>, 'lineItems'> & {
lineItems: PricingPlanLineItem[]
lineItems: [PricingPlanLineItem, ...PricingPlanLineItem[]]
}
>
@ -519,7 +557,8 @@ export const pricingPlanListSchema = z
message: 'Must contain at least one PricingPlan'
})
.describe('List of PricingPlans')
export type PricingPlanList = PricingPlan[]
export type PricingPlanListInput = [PricingPlanInput, ...PricingPlanInput[]]
export type PricingPlanList = [PricingPlan, ...PricingPlan[]]
/**
* Map from internal PricingPlanLineItem **slug** to Stripe Subscription Item id

Wyświetl plik

@ -1,6 +1,10 @@
import { expect, test } from 'vitest'
import { expect, expectTypeOf, test } from 'vitest'
import { rateLimitSchema } from './rate-limit'
import {
type RateLimit,
type RateLimitInput,
rateLimitSchema
} from './rate-limit'
test('rateLimitSchema valid', () => {
expect(
@ -54,3 +58,47 @@ test('rateLimitSchema invalid', () => {
})
).toThrowErrorMatchingSnapshot()
})
test('RateLimit types', () => {
expectTypeOf({
interval: 10,
maxPerInterval: 100
} as const).toExtend<RateLimit>()
expectTypeOf<{
interval: 10
maxPerInterval: 100
}>().toExtend<RateLimit>()
expectTypeOf({
interval: '10s',
maxPerInterval: 100
} as const).not.toExtend<RateLimit>()
expectTypeOf<{
interval: '10s'
maxPerInterval: 100
}>().not.toExtend<RateLimit>()
})
test('RateLimitInput types', () => {
expectTypeOf({
interval: 10,
maxPerInterval: 100
} as const).toExtend<RateLimitInput>()
expectTypeOf<{
interval: 10
maxPerInterval: 100
}>().toExtend<RateLimitInput>()
expectTypeOf({
interval: '3h',
maxPerInterval: 100
} as const).toExtend<RateLimitInput>()
expectTypeOf<{
interval: '3h'
maxPerInterval: 100
}>().toExtend<RateLimitInput>()
})