diff --git a/apps/www/app/pricing.json/route.ts b/apps/www/app/pricing.json/route.ts new file mode 100644 index 00000000000..42024f41084 --- /dev/null +++ b/apps/www/app/pricing.json/route.ts @@ -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 }) +} diff --git a/apps/www/lib/pricing-json.test.ts b/apps/www/lib/pricing-json.test.ts new file mode 100644 index 00000000000..5076376525e --- /dev/null +++ b/apps/www/lib/pricing-json.test.ts @@ -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()) + } + }) +}) diff --git a/apps/www/lib/pricing-json.ts b/apps/www/lib/pricing-json.ts new file mode 100644 index 00000000000..59f0df90bb0 --- /dev/null +++ b/apps/www/lib/pricing-json.ts @@ -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