Blog · · 11 min

Compare-at price: anchoring that is legal

In the EU, an announcement of a price reduction must state the prior price, and Article 6a of Directive 98/6/EC defines that prior price as the lowest one applied in at least the 30 days before the reduction. That single rule is what makes the permanent struck-through price a problem. The engineering answer is a price history, a reference computed from it, and a component that renders nothing rather than lie.

By

Striking through a price is regulated in Europe. Article 6a of Directive 98/6/EC, inserted by Directive (EU) 2019/2161, requires any announcement of a price reduction to indicate the prior price, and defines that prior price as the lowest price the trader applied during a period of at least 30 days before the reduction. That is the rule the permanent struck-through figure breaks, and every site generator emits one by default, because a compare_at_price column has no time dimension in it at all.

The rule is transposed country by country and there are carve-outs. What follows is the general shape and the engineering that makes it possible to comply; the local text is what governs you.

What does the rule actually say?

Three parts, and the third is the one people miss.

  1. It applies to an announcement of a price reduction — a struck-through figure, a percentage off, a was/now pair. A price that is simply low is not an announcement of a reduction and none of this touches it.
  2. The prior price to display is the lowest price applied over a window of not less than 30 days before the reduction begins. Not the price on the shelf last Tuesday. The lowest one in the window.
  3. Where the reduction is progressively increased, Article 6a(2) allows the prior price to be the one before the first application of the reduction. Article 6a(3) allows member states to set different rules for goods liable to deteriorate or expire rapidly, and 6a(4) allows a shorter period for products that have been on the market for less than 30 days.

Which means the answer to can I strike through this price is not a policy question. It is a query against data most stores never stored.

Why does every generated store break this?

Because of a data model, not a bad intention. Ask an agent for a product schema and you get something like price and compareAtPrice, two numbers side by side on the product row. That shape can represent this is cheaper than that. It cannot represent this was the price, from this date to that date, and no amount of copy on top of it will make the second statement true.

So the struck-through figure becomes decoration. It is set once, at launch, and it never moves, which makes it a permanent claim of a reduction that never happened. In the EU that is an announcement of a price reduction whose prior price was never applied.

The fix is not a warning in the admin. It is to give price a timeline.

How do you store a price history?

One row per price that was ever actually payable. You never update a row; you insert a new one. Deleting history is deleting the only evidence that a reduction was real.

create table price_point (
  id             bigserial   primary key,
  product_id     text        not null,
  currency       char(3)     not null,
  amount_cents   integer     not null check (amount_cents >= 0),

  -- the moment this price became the one a buyer could actually pay
  effective_from timestamptz not null default now(),

  -- false for a price the public could never reach: a staff rate, a
  -- private B2B tier, a draft. Those must not lower your reference.
  public         boolean     not null default true,

  -- who or what wrote it, because in two years you will want to know
  source         text        not null default 'admin'
);

create index price_point_lookup
  on price_point (product_id, currency, effective_from desc);

-- The rule the table exists to enforce, written where it cannot be forgotten.
comment on table price_point is
  'Append only. A price change is an INSERT. Never UPDATE amount_cents, and
   never DELETE a row: the 30-day reference price is computed from this
   timeline and a deleted row silently changes what you are allowed to show.';

Two columns carry more weight than they look. public keeps a wholesale or staff price from dragging your reference down to a number no consumer could ever have paid. source is what lets you answer, months later, why the reference moved on a particular day.

![A ring of stiff cardboard hang tags on a brass wire, hung against a dark textured wall, each tag deeply stamped with a figure and a date, the topmost one lit hard from the left and the rest falling away into shadow.](/blog/historique-des-prix.webp "The reference price is not the tag on top of the ring. It is the lowest tag in the last thirty days, which is why the whole ring has to be kept.")

How do you compute the reference price?

Here is the bug that almost every naive implementation has, and it is worth stating before the code. If you filter the rows to those inside the last 30 days, a product whose price has not changed for a year returns no rows at all, and the function concludes there is no history. The price in force when the window opened is part of the window, even though its row is much older than the window.

export type PricePoint = {
  amountCents: number
  effectiveFrom: Date
  public: boolean
}

export type Reference = {
  amountCents: number
  /** false when the timeline does not reach back across the whole window */
  coversFullWindow: boolean
}

/**
 * The lowest price actually applied over the window, walking the timeline
 * rather than filtering the rows.
 */
export function referencePrice(
  history: PricePoint[],
  now: Date,
  windowDays = 30,
): Reference | null {
  const start = new Date(now.getTime() - windowDays * 86_400_000)

  const points = history
    .filter((p) => p.public && p.effectiveFrom <= now)
    .sort((a, b) => a.effectiveFrom.getTime() - b.effectiveFrom.getTime())

  if (points.length === 0) return null

  // The price already in force when the window opened. This is the line
  // that a row-filtering implementation is missing.
  const before = points.filter((p) => p.effectiveFrom <= start).pop()
  const inside = points.filter((p) => p.effectiveFrom > start)
  const considered = before ? [before, ...inside] : inside

  if (considered.length === 0) return null

  return {
    amountCents: Math.min(...considered.map((p) => p.amountCents)),
    // Without a price in force at the start of the window, the product
    // simply has not existed at a price for 30 days.
    coversFullWindow: Boolean(before),
  }
}

Worth walking one case by hand, because it is the case that catches people out. A product sits at 48.00 for six months. On day one of the window the merchant raises it to 69.00. Three weeks later he cuts it to 55.00 and wants to strike through 69.00. The function returns 48.00, because 48.00 was in force when the window opened and it is the lowest figure in it. Against a reference of 48.00 there is no reduction to announce at all: 55.00 is higher. The struck-through 69.00 was the thing the rule was written for.

What does the component do when the data is missing?

It shows the current price and stops. There is no branch that falls back to a decorative anchor, because a fallback is exactly how the wrong figure ends up on a page.

export function Price({
  currentCents,
  reference,
  currency,
  locale,
  /** the wording differs by country, so it is a prop and not a string */
  referenceLabel = 'Lowest price in the last 30 days',
}: {
  currentCents: number
  reference: Reference | null
  currency: string
  locale: string
  referenceLabel?: string
}) {
  const money = (cents: number) =>
    new Intl.NumberFormat(locale, { style: 'currency', currency })
      .format(cents / 100)

  const announceable =
    reference !== null
    && reference.coversFullWindow
    && reference.amountCents > currentCents

  // No history, an incomplete window, or a reference that is not higher:
  // there is nothing to announce, so nothing is announced.
  if (!announceable) {
    return (
      <p className="price">
        <span className="price-now">{money(currentCents)}</span>
      </p>
    )
  }

  const saved = reference.amountCents - currentCents

  return (
    <p className="price">
      <span className="price-now">{money(currentCents)}</span>
      <s className="price-was">{money(reference.amountCents)}</s>
      <span className="price-note">
        {referenceLabel}. You save {money(saved)}.
      </span>
    </p>
  )
}

Three details in there that are not cosmetic.

  • The saving is shown in money, not as a percentage. A percentage is an extra arithmetic step at the exact moment you wanted a decision, and the buyer has to know the reference figure to run it.
  • referenceLabel is a prop because several transpositions expect specific wording next to the struck-through figure, and hardcoding an English sentence into a component that ships across borders guarantees it will be wrong somewhere.
  • <s> rather than a CSS class. The struck-through figure is semantically no longer accurate, which is precisely what the s element means. A styled span says the same thing to a sighted buyer and nothing at all to anyone else.

What can you show, case by case?

SituationReference priceWhat the component renders
Price unchanged for six months, now cutthe old price, in force at the window's startcurrent price, old price struck through
New product, on the market eight daysnone that covers the windowcurrent price only
Raised, then cut back below the raisethe pre-raise price, which is the lowestusually no reduction to announce
Raised, then cut to below the pre-raise pricethe pre-raise pricereduction announced against that lower figure
Progressive campaign, deepening weeklyArticle 6a(2) allows the price before the first stepcheck the national transposition
Wholesale tier lower than retailignored, public is falseunaffected
Perishable goodsmember states may set different rulescheck the national transposition

Two rows say check the national transposition and they mean it. The directive sets the frame; the enforceable text is the national consumer or commercial code, and it is not identical in two countries.

What to do instead of a fake anchor

The reason merchants reach for the permanent struck-through price is that they want a number for the buyer to compare against. There is a legitimate one, and it is better: the unit price at a higher quantity.

Three tins at 48.00 against one at 19.00 is not a claim about the past. It is arithmetic about the present, entirely under your control, checkable on the page, and outside the scope of prior-price rules because no reduction on a previous price is being announced. That is the whole mechanic in quantity breaks that raise average order value, and its component shape is in a bundle quantity selector for React.

The other thing not to reach for is a clock. Wrapping a struck-through price in a timer that restarts on every visit turns a price problem into an urgency problem, and that has its own article: a countdown timer that is honest.

FAQ

Is a permanent struck-through price legal in the EU?

An announcement of a price reduction has to state the prior price, and Article 6a of Directive 98/6/EC as inserted by Directive (EU) 2019/2161 defines that as the lowest price applied in at least the 30 days before the reduction. A figure struck through permanently, that was never charged during that window, does not meet the definition. The rule is transposed nationally with local variations and enforcement, so verify the text that applies where you sell.

What counts as the prior price for a discount?

The lowest price you actually applied during a period of at least 30 days before the reduction starts, not the price immediately before it. If you raised the price two weeks ago and are now cutting it, the reference is still the lower pre-raise figure, and there may be no reduction left to announce at all.

How do I store price history for compare-at pricing?

One append-only row per price that was actually payable, carrying the amount, the currency, the moment it took effect, and a flag for whether the public could reach it. Never update the amount on an existing row, and never delete one: the 30-day reference is computed by walking that timeline, and a missing row silently changes what you are allowed to display.

What should a price component render when there is no history?

The current price on its own. No struck-through figure, no percentage, no placeholder anchor. A component with a fallback path to a decorative comparison will use it, usually on the day nobody is watching, and that is how an unfounded reduction claim ends up in production.

The component, if you would rather not write it

uxgen.ai is an MCP server that gives Claude 168 commerce components and places them in the store as HTML the merchant owns: nothing to uninstall, no per-order commission, $19, $29 or $59 a month. Several of the rules above are encoded as constraints in the code rather than as documentation, which is the only form of a rule that survives a hurried configuration. The cart components are published under MIT at github.com/kinerette/uxgen-commerce-kit.

uxgen is a service of UXGen AI, LLC — 131 Continental Dr, Suite 305, Newark, DE 19713, United States.

© 2026 UXGen AI, LLC. All rights reserved.