Pricing and plans
config/pricing.ts is the only place plans and prices live - the three plan kinds, test and live price ids, retiring a plan, other payment providers, and coupons.
Outcome: the pricing page shows your plans, and pnpm tsx scripts/check-stripe.ts prints a ✓ line for every plan.
One file
Every plan lives in config/pricing.ts, in the pricingPlans list. The pricing page, checkout and the payment webhooks all read this file. The database stores no prices, and there is no pricing screen in the admin dashboard. A price change is a code change: the agent edits the file, you review it, and it goes live with the next deploy.
Each plan is one object holding everything about it: its id, its kind, the price, which payment provider sells it, that provider's price id, and the card text in every language.
To change your plans, tell the agent what you sell, for example:
Replace the plans with:
- Starter: $9.90 a month, 1,000 credits a month
- Pro: $99 a year, 2,000 credits a month
- A one-time pack: $5.90 for 300 credits
Write the card text in all eight languages.
The agent follows step 2 of skills/setup-stripe/SKILL.md.
The three kinds
License: a one-time purchase, no credits
The starter ships with two of these. This is the real entry, with the card text shortened to English:
{
id: 'license-yearly',
kind: 'license',
term: 'year', // 'year' or 'lifetime'
provider: 'stripe',
stripePriceId: {
test: 'price_REPLACE_license_yearly_test',
live: 'price_REPLACE_license_yearly_live',
},
price: 79,
currency: 'USD',
active: true,
copy: {
en: {
name: 'One year',
description: 'The full starter and every skill, with one year of updates.',
features: ['Full source code, unlimited projects', 'Updates and new skills for **12 months**'],
},
// zh, ja, fr, de, ko, es, it …
},
},
Subscription: credits every month
{
id: 'pro-monthly',
kind: 'subscription',
interval: 'month', // or 'year'
monthlyCredits: 2000,
provider: 'stripe',
stripePriceId: {
test: 'price_REPLACE_pro_monthly_test',
live: 'price_REPLACE_pro_monthly_live',
},
price: 29.9,
currency: 'USD',
active: true,
copy: { en: { name: 'Pro', description: '...', features: ['...'] } },
},
- Subscription credits reset to
monthlyCreditsat each grant; unused credits do not roll over. - A yearly plan (
interval: 'year') still grantsmonthlyCreditsonce a month. The/api/cron/creditsscheduled job settles those grants (see the deploy pages); a due grant is also settled when the user's balance is read. - Optional fields:
trialDays, andaudience: 'team'withseats: 5for a plan sold to a team (the credits go into the team's shared pool, andseatscaps its members).
Credit pack: a one-time purchase of credits
{
id: 'pack-mini',
kind: 'credit_pack',
credits: 300, // never expire
provider: 'stripe',
stripePriceId: {
test: 'price_REPLACE_pack_mini_test',
live: 'price_REPLACE_pack_mini_live',
},
price: 5.9,
currency: 'USD',
active: true,
copy: { en: { name: 'Mini Pack', description: '...', features: ['...'] } },
},
Fields every plan has
| Field | What it does |
|---|---|
id |
a stable slug; orders and credit history point to it forever |
price, currency |
what the card shows; the amount charged comes from the provider |
originalPrice |
optional strikethrough price |
popular |
highlights the card |
active |
false hides the plan from the pricing page and keeps everything else working |
promotionCode |
optional code applied automatically at checkout for this plan |
copy |
card text per language; en is required and is used for any language without its own |
In card features, **text** is shown bold. A feature written as { text: '…', highlight: true } stands out from the rest; use it for team-only perks.
Test and live price ids
Every Stripe plan has two price ids: one from Stripe's test mode, one from live mode.
stripePriceId: { test: 'price_…', live: 'price_…' },
The site picks the column from the key it runs with: an sk_test_ key uses test, an sk_live_ (or rk_live_) key uses live. You never switch modes in the file.
You do not create prices in the Stripe dashboard. Leave the placeholders (price_REPLACE_…) in a new plan and the agent runs:
pnpm stripe:bootstrap # dry run: shows what it would create
pnpm stripe:bootstrap --write # creates the products and prices, writes the ids into the file
It fills the column of the current key's mode. Run it once with the test key during setup, and once more with the live key before launch to fill the live column. It is safe to rerun: it reuses products and prices that already exist.
To change the price of a plan that is already on sale, do not edit the price in Stripe. Change price in the file and ask the agent for a new Stripe price; check-stripe.ts flags a plan whose amount no longer matches.
Never delete or reuse a sold plan id
Once a plan has been sold, its entry stays in the file forever.
- To stop selling it, set
active: false. It disappears from the pricing page. Renewals of existing subscribers keep working. - Do not delete the entry. Every renewal invoice is matched to its plan through this file. Without the entry, each renewal fails, Stripe retries for up to three days, and you get an alert mail on every attempt.
- Do not give the
idto a different offer. Old orders and credit history would then show the new offer's name.
A new offer gets a new id, for example pro-monthly-2027.
Other payment providers: PayPal and Creem
Each plan has exactly one provider, set in provider. One plan is one provider; to sell the same offer through two providers, make two plans with two ids.
| Provider | Reference field | Notes |
|---|---|---|
| Stripe | stripePriceId |
the default; filled by pnpm stripe:bootstrap |
| Creem | creemProductId |
a Creem product id, test and live; needs CREEM_API_KEY, CREEM_WEBHOOK_SECRET |
| PayPal | paypalPlanId |
a PayPal billing plan id, for subscriptions only; a PayPal credit pack needs no id, its order is created from price. Needs NEXT_PUBLIC_ENABLE_PAYPAL=true and the PayPal keys |
config/pricing.ts has a commented example for each: a Creem subscription, a PayPal subscription and a PayPal credit pack. There is no setup skill for PayPal or Creem in this release; the agent works from those examples and skills/reference/payments.md. A PayPal site also needs the /api/cron/credits scheduled job.
Coupons
Coupons are Stripe promotion codes, managed in the admin dashboard: Dashboard → Admin → Coupons.
- Create: New coupon → a code (typed or generated), percent or amount off, how long it applies (first payment or longer), an expiry, a redemption limit, and which plans it applies to. The dialog sums up what the customer gets.
- Change: a code cannot be edited once created. You can switch it off and on, or duplicate it into a new code.
- Stripe stores the codes; nothing is kept in your database. Coupons apply to Stripe plans only, and the page shows a notice when Stripe is not configured.
Customers type the code at checkout. To apply one automatically instead, set promotionCode on a plan, or pricingCampaign.promotionCode in config/pricing.ts for every plan. Create the same code in test and live mode.
When it fails
| What you see | Cause | Fix |
|---|---|---|
pnpm lint fails after a plan edit |
a field is missing or belongs to another kind (for example credits on a subscription) |
the agent fixes the entry; the plan types are strict on purpose |
price … not found in test mode |
the id in the file is from the other mode | the agent reruns the bootstrap with the matching key |
✗ … amount ≠ from check-stripe.ts |
the price in Stripe differs from price in the file |
change the file back, or create a new Stripe price |
| A plan is missing from the pricing page | active: false |
set active: true if it should be on sale |
| Renewals fail with webhook errors after a cleanup | a sold plan entry was deleted | restore the entry from git with active: false |
| The Coupons page says coupons are unavailable | STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET is not set |
finish the Stripe integration |