A Course had a price column. An integer. 149.

Here is what Paddle actually charges for one of our sandbox Prices, depending on where the buyer is standing:

Where the Student isWhat Paddle charges
BrazilR$ 300,00
Portugal€ 60,00
United States$ 79,00

Three amounts, one Price. And look closely: they are not conversions of each other. R$ 300 is about €48, not €60. These are purchasing-power-parity prices, set deliberately per market. There is no exchange rate that turns one into the others, and therefore no single integer that is right for more than one of these Students.

So what was 149 doing? It was rendering to the page, server-side, for a fraction of a second before Paddle’s real number arrived and replaced it. A Brazilian visitor briefly saw €149, then watched it flip to R$ 300,00. We shipped a wrong number, quickly, to everyone.

This slice deletes the column. ADR-0012 was written and accepted before the code existed; this is the slice that makes the code match it.

The integer had a second job

The obvious problem is the misleading amount. The subtler one is that price was moonlighting.

if course.price == 0, do: "Free", else: "€#{course.price}"

Free-vs-paid was inferred from the number. 0 meant free. Anything else meant paid. Which means “is this Course free?” — a genuine product question with a genuine answer — was being derived from a display field that we’d just established is meaningless across borders.

It also produced a third state nobody named. A Course with price > 0 but no linked Paddle Price is not free, and not purchasable either: there is nothing to charge against. It was misconfigured, and the only reason the catalog handled it at all was an else branch that happened to catch it.

Two questions were tangled in one column:

  • Is this Course free? → a product decision the Instructor makes
  • What number do we print while Paddle loads? → a rendering concern with no correct answer

The fix is to answer the first one explicitly and stop asking the second.

An explicit Pricing model

pricing is now an Ecto.Enum:free or :paid — and it is the whole truth about how a Course sells.

stateDiagram-v2
    [*] --> Free: Instructor picks "Free"
    [*] --> Paid: Instructor picks "Paid"

    Free: Free
    Free: self-enroll · no Paddle · catalog shows "Free"

    Paid --> Purchasable: Paddle Price linked
    Paid --> Misconfigured: no Paddle Price

    Purchasable: Purchasable
    Purchasable: Buy · amount comes from Paddle alone

    Misconfigured: Misconfigured
    Misconfigured: not for sale · flagged to the Instructor

    Misconfigured --> Purchasable: paste a pri_… id
    Purchasable --> Free: switch to Free

The predicate that decides everything:

def purchasable?(%__MODULE__{pricing: :paid, paddle_price_id: id}),
  do: is_binary(id) and id != ""

def purchasable?(%__MODULE__{}), do: false

Paid and linked. Note the second clause: a Course switched back to free stays un-purchasable even if a stale pri_… is still sitting in the column. The Pricing model decides. Nothing is inferred from the presence of data — which is precisely the habit that got us here.

misconfigured?/1 is now a first-class predicate rather than a fall-through, and the Instructor is told about it:

The Settings tab: a paid Course with no Paddle Price ID, flagged as misconfigured

The standalone “Price (€)” input is gone. In its place, a two-card radio group — the same .mm-radio-card the Visibility field already uses — and a Paddle Price ID field that only appears once you say the Course is paid, because until then it means nothing.

The skeleton, and nothing but the skeleton

Here is the part that felt uncomfortable to write.

The old price block rendered €149 server-side and let JavaScript correct it. The new one renders nothing:

<div id="pp-price-block" data-price-block phx-update="ignore">
  <div data-price-loading>
    <div class="pp-skeleton pp-skeleton-price"></div>
    <div class="pp-skeleton pp-skeleton-note"></div>
  </div>
  <div data-price-content style="display:none">
    <span class="pp-price-currency" data-price-currency></span>
    <span class="pp-price-amount" data-price-amount></span>
  </div>
  ...
</div>

Empty spans. A shimmer. The instinct to put something in there — a , a , the old integer as “just a hint” — is exactly the instinct ADR-0010 gave in to, and it’s the one being reversed. A brief shimmer says “the price is coming.” A wrong number says “the price is €149,” which is a lie we then take back.

The paid course detail page while Paddle’s price preview is in flight — a shimmer, no number

A moment later, Paddle answers, and the amount is its string — fully formatted, symbol included, which is why the currency span stays empty rather than prefixing a second R$:

The same page once Paddle resolves: R$ 300,00, localized

And when the preview fails, the page says so, in words, instead of falling back to a number:

A failed price preview: “Price unavailable right now — reload to try again.”

The same rule governs the catalog cards. Free cards say “Free”. Paid cards shimmer until Paddle resolves them in one batched call. Misconfigured cards say “Not available”.

The catalog mid-flight: a free card, a shimmering paid card, and a misconfigured card

A blink later, Paddle has answered for every paid card at once:

The same catalog once Paddle resolves — the shimmer replaced by R$ 300,00

The test that matters is not “does the skeleton exist” — it’s that no digit ever leaves the server:

refute block =~ ~r/\d/
refute block =~ "€"

Where the mockup was wrong

The design system’s purchase-surfaces mockup shows the misconfigured panel with a price on it — a struck-out-looking €149 above a disabled Buy button.

We dropped it. That €149 could only have come from the local integer, and the local integer no longer exists. A misconfigured Course has no Paddle Price by definition, so there is nothing to preview and nothing to print; a skeleton there would shimmer for eternity, promising a number that is never coming. The panel leads with the disabled button instead.

The misconfigured purchase panel: disabled Buy, no price, no enroll path

A design system is a strong default, not a court of final appeal. When the mockup was drawn, a local price existed. It doesn’t now.

Deleting a column that’s holding data

The migration has to convert before it destroys:

def up do
  alter table(:courses) do
    add :pricing, :string
  end

  execute "UPDATE courses SET pricing = CASE WHEN price > 0 THEN 'paid' ELSE 'free' END"

  alter table(:courses) do
    modify :pricing, :string, null: false
    remove :price
  end
end

Add, backfill, constrain, drop — in that order, in one transaction. All 21 Courses in the dev database backfilled to free, which is correct: every one of them had price = 0.

A one-shot data conversion gets exactly one chance to be right, so it is tested, against the real migration module rather than a retyped copy of its SQL:

test "a Course with a price above 0 backfills to paid" do
  insert_course("paid-course", 149)
  run(:up)
  assert pricing_of("paid-course") == "paid"
end

Postgres DDL is transactional and Ecto’s sandbox rolls its transaction back at checkin, so the table is reshaped, asserted against, and restored — without leaving a crater for the next test.

The down migration is where the honesty gets uncomfortable. It can restore the column, but not the amounts: Paddle owns those, and there is nothing local to recover them from. Paid Courses roll back to price = 1 — a marker that keeps price > 0 truthful, not a real price. That asymmetry is the point. The data was never ours to begin with.

What actually changed

The whole slice, in one line: price is gone from lib/ and test/ entirely — no column, no price == 0, no fallback_amount/1. Three checks were quietly deciding product behavior off a display field, and all three are now asking the Pricing model instead.

The catalog no longer tells a Brazilian Student that a course costs €149. It tells them nothing at all, for about 400 milliseconds, and then it tells them the truth.