feat(www): publish machine-readable pricing.json

This commit is contained in:
Pamela Chia committed 2026-08-26 17:24:33 +08:00
1 parent ec8ed5f573
commit fc4d5cabc5
3 files changed
+232

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server'
import { buildPricingJson } from '@/lib/pricing-json'
export const dynamic = 'force-dynamic'
const PRICING_JSON_HEADERS = {
'Content-Type': 'application/json; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
}
export async function GET() {
return NextResponse.json(buildPricingJson(), { headers: PRICING_JSON_HEADERS })
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { buildPricingJson, parseUsdString } from './pricing-json'
describe('parseUsdString', () => {
it('parses plain dollar amounts', () => {
expect(parseUsdString('$10')).toBe(10)
expect(parseUsdString('$1,870')).toBe(1870)
expect(parseUsdString('$0.024')).toBe(0.024)
})
it('throws on anything that is not a bare dollar amount', () => {
expect(() => parseUsdString('Contact Us')).toThrow('Unparseable price')
expect(() => parseUsdString('10')).toThrow('Unparseable price')
expect(() => parseUsdString('$10/mo')).toThrow('Unparseable price')
expect(() => parseUsdString('')).toThrow('Unparseable price')
})
})
describe('buildPricingJson', () => {
const payload = buildPricingJson()
const planKeys = ['free', 'pro', 'team', 'enterprise']
it('declares itself informational', () => {
expect(payload.description).toContain('not a billing API')
expect(payload.source).toBe('https://supabase.com/pricing')
expect(payload.currency).toBe('USD')
})
it('covers all four plans with billing semantics', () => {
expect(payload.plans.map((p) => p.id)).toEqual(planKeys)
for (const plan of payload.plans) {
expect(typeof plan.spendCapAvailable).toBe('boolean')
if (plan.id === 'enterprise') {
expect(plan.priceMonthly).toBeNull()
expect(plan.contactSales).toBe(true)
} else {
expect(Number.isFinite(plan.priceMonthly)).toBe(true)
}
}
})
it('parses every compute tier with numeric prices', () => {
expect(payload.compute.tiers.length).toBeGreaterThanOrEqual(10)
const contactTiers = payload.compute.tiers.filter((t) => t.contactSales)
expect(contactTiers).toHaveLength(1)
for (const tier of payload.compute.tiers) {
if (tier.contactSales) {
expect(tier.priceMonthly).toBeNull()
continue
}
expect(Number.isFinite(tier.priceMonthly)).toBe(true)
expect(tier.priceMonthly!).toBeGreaterThan(0)
expect(Number.isFinite(tier.memoryGb)).toBe(true)
expect(Number.isFinite(tier.connectionsDirect)).toBe(true)
expect(Number.isFinite(tier.connectionsPooler)).toBe(true)
}
})
it('carries exactly the gp3 and io2 disk types with numeric rates', () => {
expect(payload.disk.types.map((d) => d.type)).toEqual(['gp3', 'io2'])
const gp3 = payload.disk.types[0]
expect(gp3.includedPerProject).toEqual({ sizeGb: 8, iops: 3000, throughputMBps: 125 })
expect(gp3.perUnitMonth.iops).toBeGreaterThan(0)
expect(gp3.perUnitMonth.throughputMBps).toBeGreaterThan(0)
const io2 = payload.disk.types[1]
expect(io2.includedPerProject).toBeNull()
expect(io2.perUnitMonth.throughputMBps).toBeNull()
})
it('gives every meter a scope, positive price, and complete includedByPlan', () => {
expect(payload.meters.length).toBeGreaterThanOrEqual(15)
for (const meter of payload.meters) {
expect(meter.scope).toMatch(/^per-/)
expect(meter.pricePerUnitQuantity).toBeGreaterThan(0)
expect(meter.unitQuantity).toBeGreaterThan(0)
expect(Object.keys(meter.includedByPlan).sort()).toEqual([...planKeys].sort())
}
})
it('keeps unified egress out of any product bucket', () => {
const egress = payload.meters.find((m) => m.id === 'egress')
expect(egress?.unified).toBe(true)
expect(egress?.product).toBeNull()
})
it('lists every addon with per-project scope and availability', () => {
expect(payload.addons.map((a) => a.id).sort()).toEqual([
'advancedMfaPhone',
'customDomain',
'logDrain',
'pitr',
])
for (const addon of payload.addons) {
expect(addon.scope).toBe('per-project')
expect(Object.keys(addon.availability).sort()).toEqual([...planKeys].sort())
}
})
})
+118
View File
@@ -0,0 +1,118 @@
import { plans } from 'shared-data/plans'
import { ADDONS, DISK_PRICING, METERS, PLAN_BILLING, usd } from 'shared-data/pricing-catalog'
import addOnTable from '@/data/PricingAddOnTable.json'
export function getColumnValue(
row: { columns: { key: string; value: unknown }[] },
key: string
): unknown {
const col = row.columns.find((c) => c.key === key)
if (!col) throw new Error(`Missing column "${key}"`)
return col.value
}
export function parseUsdString(value: string): number {
const match = /^\$([\d,]+(?:\.\d+)?)$/.exec(value)
if (!match) throw new Error(`Unparseable price: ${value}`)
return Number(match[1].replace(/,/g, ''))
}
function parseGbString(value: string): number {
const match = /^([\d.]+)[  ]GB$/.exec(value)
if (!match) throw new Error(`Unparseable size: ${value}`)
return Number(match[1])
}
function parseCountString(value: string): number {
const match = /^[\d,]+$/.exec(value)
if (!match) throw new Error(`Unparseable count: ${value}`)
return Number(value.replace(/,/g, ''))
}
interface ComputeTier {
name: string
priceMonthly: number | null
contactSales?: true
cpu?: string
dedicated?: boolean
memoryGb?: number
connectionsDirect?: number
connectionsPooler?: number
}
function buildComputeTiers(): ComputeTier[] {
return addOnTable.database.rows.map((row) => {
const name = String(getColumnValue(row, 'plan'))
const pricing = String(getColumnValue(row, 'pricing'))
if (pricing === 'Contact Us') {
return { name, priceMonthly: null, contactSales: true as const }
}
return {
name,
priceMonthly: parseUsdString(pricing),
cpu: String(getColumnValue(row, 'cpu')),
dedicated: Boolean(getColumnValue(row, 'dedicated')),
memoryGb: parseGbString(String(getColumnValue(row, 'memory'))),
connectionsDirect: parseCountString(String(getColumnValue(row, 'directConnections'))),
connectionsPooler: parseCountString(String(getColumnValue(row, 'poolerConnections'))),
}
})
}
export function buildPricingJson() {
return {
description:
'Machine-readable pricing for https://supabase.com/pricing. Informational, not a billing API. Monthly totals depend on plan quotas, compute credits, and the spend cap encoded below; metered prices apply only beyond includedByPlan quotas.',
source: 'https://supabase.com/pricing',
docs: 'https://supabase.com/docs/guides/platform/org-based-billing',
currency: 'USD',
plans: plans.map((plan) => {
const billing = PLAN_BILLING[plan.planId]
return {
id: plan.planId,
name: plan.name,
priceMonthly: billing.priceMonthly,
...(plan.planId === 'enterprise' ? { contactSales: true as const } : {}),
computeCreditsMonthly: billing.computeCreditsMonthly,
spendCapAvailable: billing.spendCapAvailable,
}
}),
compute: {
scope: 'per-project',
note: `Every project runs on its own compute instance, billed hourly. Pro and Team plans include ${usd(PLAN_BILLING.pro.computeCreditsMonthly)}/month in compute credits, which covers one Micro instance.`,
tiers: buildComputeTiers(),
},
disk: {
scope: 'per-project',
types: Object.values(DISK_PRICING).map((disk) => ({
type: disk.type,
name: disk.displayName,
maxSizeTb: disk.maxSizeTb,
durabilityPercent: disk.durabilityPercent,
includedPerProject: disk.includedPerProject,
perUnitMonth: disk.perUnitMonth,
...(disk.throughputNote ? { throughputNote: disk.throughputNote } : {}),
})),
},
meters: Object.entries(METERS).map(([id, meter]) => ({
id,
product: meter.product,
...('unified' in meter && meter.unified ? { unified: true as const } : {}),
scope: `per-${meter.scope}`,
unit: meter.unit,
unitQuantity: meter.per,
pricePerUnitQuantity: meter.price,
includedByPlan: meter.includedByPlan,
...('billingNote' in meter && meter.billingNote ? { billingNote: meter.billingNote } : {}),
})),
addons: Object.entries(ADDONS).map(([id, addon]) => {
const { scope, availability, ...prices } = addon
return { id, scope: `per-${scope}`, ...prices, availability }
}),
}
}
export type PricingJson = ReturnType<typeof buildPricingJson>