Pricing config
All pricing on this site is rendered from one typed config object. Edit it in one place and every pricing surface updates.
The source of truth
The file app/lib/pricing.ts is the single source of truth for pricing on the marketing site. The landing page pricing section, the /pay flow, and this doc all import from it. No page hardcodes a price.
Why one file? The numbers mirror the product pricing draft in pura-app/docs/features/18-pricing.md. When that draft changes, you change this config and the whole site follows — no hunting through JSX for stray prices.
Config shape
The config exports a typed PLANS array plus metadata and helpers:
import {
PLANS, // readonly Plan[]
CURRENCIES, // ["EUR", "USD", "CAD", "AUD"]
BILLING_CYCLES, // ["monthly", "yearly"]
METER, // { unit, unitPlural, blurb, resetCadence }
formatPrice, // (amount, currency) => "€99"
formatLimit, // (n | null) => "5,000" | "Unlimited"
planAmount, // (plan, currency, cycle) => number | null
getPlan, // (id) => Plan | undefined
} from "@/lib/pricing";Each plan looks like this:
{
id: "pro_v1",
name: "Pro",
tagline: "For orgs scaling review across many repos.",
badge: "Most popular",
custom: false, // true → no fixed price (Enterprise)
trialDays: null,
pricing: { // null when custom
EUR: { monthly: 499, yearly: 4999 },
USD: { monthly: 499, yearly: 4999 },
CAD: { monthly: 599, yearly: 5999 },
AUD: { monthly: 599, yearly: 5999 },
},
overage: { EUR: 25, USD: 25, CAD: 35, AUD: 35 }, // per 1,000 commits
limits: {
reviewedCommitsPerMonth: 50000, // null = unlimited
repositories: 100,
organizations: 3,
dashboardSeats: 25,
auditRetentionDays: 365,
},
highlights: ["50,000 reviewed commits / month", /* ... */],
cta: { label: "Get started", kind: "install" }, // "install" | "contact"
}Edit a plan
To change the Basic monthly price and its commit allowance, edit the entry in PLANS:
// app/lib/pricing.ts
{
id: "basic_v1",
// ...
pricing: {
EUR: { monthly: 129, yearly: 1299 }, // changed
USD: { monthly: 129, yearly: 1299 },
CAD: { monthly: 169, yearly: 1699 },
AUD: { monthly: 169, yearly: 1699 },
},
limits: {
reviewedCommitsPerMonth: 8000, // changed
// ...
},
}That's it — the landing page cards, the per-cycle toggle, the allowance line, and the currency switcher all reflect the change with no JSX edits.
Add a currency
Currencies are type-checked, so adding one is a guided change. Add the code to the union and the lists, then TypeScript will flag every plan missing an amount:
export type CurrencyCode = "EUR" | "USD" | "CAD" | "AUD" | "GBP";
export const CURRENCIES: readonly CurrencyCode[] =
["EUR", "USD", "CAD", "AUD", "GBP"] as const;
export const CURRENCY_SYMBOL: Record<CurrencyCode, string> = {
EUR: "€", USD: "$", CAD: "CA$", AUD: "A$", GBP: "£",
};Type safety does the work: because PlanPricing is Record<CurrencyCode, …>, the build fails until every non-custom plan has a GBP amount. No silently-missing prices.
Render it in a page
A minimal pricing table is just a map over PLANS:
import { PLANS, formatPrice, formatLimit } from "@/lib/pricing";
export function MiniPricing({ currency = "EUR", cycle = "monthly" }) {
return PLANS.map((plan) => (
<div key={plan.id}>
<h3>{plan.name}</h3>
<p>
{plan.pricing
? formatPrice(plan.pricing[currency][cycle], currency)
: "Custom"}
</p>
<small>
{formatLimit(plan.limits.reviewedCommitsPerMonth)} reviewed commits / mo
</small>
</div>
));
}The full landing-page section in app/page.tsx adds a currency selector and a monthly/yearly toggle on top of exactly this pattern.
Rules & gotchas
- Never hardcode a price in JSX. Import from
@/lib/pricingso there is one place to change. - Custom plans set
pricing: nullandcustom: true; render"Custom"and acontactCTA. - Limits use
nullfor "unlimited" — always pass throughformatLimit. - Keep it in sync with §18 of the product pricing draft. The draft is the human source of truth; this file is the machine source of truth.
- Currency conversion rules (CAD/AUD +25% rounded to 49/99; yearly = monthly ×10 ending in 9) are documented in §18.3 — bake the resulting numbers into the config, don't compute them at runtime.