mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
feat(www): publish machine-readable pricing.json
This commit is contained in:
1 parent
ec8ed5f573
commit
fc4d5cabc5
3 files changed
+232
No files matched your search
@@ -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 })
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user