LaunchKit

Editing the pricing table from the admin, without a deploy

September 17, 2026

The price on the landing page and the price Stripe charges are two different pieces of data, and the gap between them is where this goes wrong. A console that edits pricing has to be honest about which one it is touching.

The admin hub covers the rest of the console.

Three places a plan lives

To edit pricing without a deploy, the Rails pricing admin writes to Setting.pricing, a jsonb column holding the whole table: the plans, their copy per locale, the annual discount, the coupon, and the Stripe ids. That column is the top of a three-layer stack and it wins whenever it is populated.

Underneath it sit the locale files and the credentials. A fresh clone has an empty Setting.pricing and still renders a pricing page, because Pricing.plans falls back to the pricing.plans.* keys in config/locales/en.yml and to the price ids under stripe.prices in the credentials. That fallback is what makes the boilerplate bootable before anybody opens the console, and it is also what makes the first admin save feel like nothing happened: the table looked right already.

Below both, in Stripe, are the Product and Price objects. Those are the only ones that move money.

Three stacked layers. The top one, in green and labelled wins when set, is Setting.pricing as a jsonb column, edited in the admin and live on save with no deploy. The middle one, labelled fallback, is the locale files plus credentials, what a fresh clone renders before anyone opens the console. The bottom one, in yellow and labelled immutable, is the Stripe Price, never updated and only replaced, the only one that charges. A box on the right labelled Checkout points an arrow at the bottom layer and reads: reads the price id. A note beside the stack says the amount you edit up here is copy, the price id down there is the bill.

Which number the checkout reads

A plan in the admin carries an amount in cents and a Stripe price id, and only one of them reaches the customer's card. The checkout session is built from the price id. The amount is what the page prints.

Editing the amount in the console therefore changes the landing page, the pricing table and nothing else. The customer still pays whatever the Stripe Price says. Two people have to notice this for it to be caught, because the page is now consistent with itself and wrong only against a system nobody has open.

This is why the console creates Stripe objects rather than just recording their ids: the correct action after changing a price is always to mint a new Price and repoint the plan, and a form that makes that the easy path is a form that keeps the two numbers in step.

Why the console creates prices instead of editing them

def create_products
  render_result Billing::Pricing::CreatePlanProducts.new(
    name: params[:name], monthly_cents: params[:monthly_cents],
    currency: params[:currency], annual_discount_percent: params[:annual_discount_percent],
    mode: params[:mode].to_s
  ).call
end

Stripe Price objects are immutable. There is no update call that changes an amount, by design, because invoices and subscriptions already reference that id and rewriting it would rewrite history. The only move available is to create a new Price and stop using the old one, which stays in your account forever attached to whoever is already paying it.

FetchPrice covers the other direction, pasting an id that already exists and reading back its amount and currency to confirm the plan is pointing where you think.

The parameter that had to reach the service

mode in that call is there because of a real failure. A plan is either a subscription or a one-time purchase, and the service creates different objects for each: a monthly and a yearly Price for the first, a single Price for the second.

When mode was not passed, the service defaulted to the subscription shape, so a one-time plan came back holding a recurring monthly price and an annual one. Nothing raised. The plan looked configured, the console showed two price ids, and the first buyer would have been enrolled in a subscription for a product sold once.

The lesson generalises past this codebase. A service that has a sensible default for a parameter the caller always knows is a service that will one day be called without it, and the failure will be silent because the default is valid.

The cache reset nobody sees

Setting.current.update!(pricing: build_pricing)
Setting.reset_cache!

Setting.current is memoised, which is correct: every page render reads the pricing config and hitting the database each time for a row that changes twice a year would be waste. The consequence is that writing the row is not enough. Without the reset, the console reports success and the site keeps serving the previous table until the process restarts, which on a single dyno means until the next deploy.

That is the shape of most caching bugs in an admin: the write succeeded, the read is stale, and the person who made the change is the only one who cannot see it because their own request came after the reset.

What this page does not cover

Nothing here is about what happens after the buyer clicks. The webhook that records the payment, the subscription state machine and the coupon mechanics are all separate problems with their own failure modes, and none of them are visible from the pricing screen.

Also out of scope: multi-currency. The table holds one currency per plan, and a real multi-currency setup means Stripe's own currency options on the Price, not a second column in this jsonb.

More on A Rails admin panel

← All A Rails admin panel articles