ShipAgent

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 monthlyCredits at each grant; unused credits do not roll over.
  • A yearly plan (interval: 'year') still grants monthlyCredits once a month. The /api/cron/credits scheduled job settles those grants (see the deploy pages); a due grant is also settled when the user's balance is read.
  • Optional fields: trialDays, and audience: 'team' with seats: 5 for a plan sold to a team (the credits go into the team's shared pool, and seats caps 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 id to 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