Dynamic Pricing Without Creating a Pricing Incident

typescript dev.to

Static prices are easy to reason about until the market moves faster than your admin panel. A competitor starts a flash sale, your inventory drops to 12 units, or a slow-moving SKU sits untouched for two weeks. If a human has to notice the change, decide what to do, and update prices manually, you are already late.

Dynamic pricing is just automated decision-making around price. The hard part is not changing the number. The hard part is changing it without violating margin rules, confusing customers, or teaching your system to chase bad data.

Start with constraints, not the algorithm

A common mistake is to start with “AI pricing” or a competitor-matching rule before defining what the system must never do.

For most e-commerce systems, those constraints look like this:

  • Never sell below minimum margin
  • Never exceed a maximum allowed price
  • Do not change the same SKU more than N times per day
  • Do not react to competitor data older than a set threshold
  • Do not move price by more than a fixed percentage per update
  • Respect MAP or channel-specific pricing rules where they apply

These constraints matter because pricing bugs are visible immediately. If your recommendation service returns a bad product sort order, users may not notice. If your repricer drops a $900 item to $90, they will.

Here is a simple rule-based repricer in TypeScript. It is not fancy, but it shows the shape of a safer implementation.

type PricingInput = {
  currentPrice: number;
  unitCost: number;
  inventory: number;
  salesLast24h: number;
  competitorPrice?: number;
  competitorSeenAt?: Date;
  now: Date;
};

type PricingConfig = {
  minMarginPct: number;
  maxPrice: number;
  maxMovePct: number;
  competitorFreshnessMinutes: number;
  lowInventoryThreshold: number;
  targetUndercutPct: number;
};

function clamp(value: number, min: number, max: number) {
  return Math.max(min, Math.min(max, value));
}

function priceSku(input: PricingInput, config: PricingConfig) {
  const minPrice = input.unitCost * (1 + config.minMarginPct / 100);
  let candidate = input.currentPrice;

  const competitorIsFresh =
    input.competitorPrice !== undefined &&
    input.competitorSeenAt !== undefined &&
    input.now.getTime() - input.competitorSeenAt.getTime() <=
      config.competitorFreshnessMinutes * 60_000;

  if (competitorIsFresh) {
    candidate = input.competitorPrice! * (1 - config.targetUndercutPct / 100);
  }

  if (input.inventory <= config.lowInventoryThreshold && input.salesLast24h > 0) {
    candidate *= 1.05;
  }

  if (input.salesLast24h === 0 && input.inventory > config.lowInventoryThreshold) {
    candidate *= 0.97;
  }

  const maxMove = input.currentPrice * (config.maxMovePct / 100);
  const boundedByMove = clamp(
    candidate,
    input.currentPrice - maxMove,
    input.currentPrice + maxMove
  );

  const finalPrice = clamp(boundedByMove, minPrice, config.maxPrice);

  return Number(finalPrice.toFixed(2));
}
Enter fullscreen mode Exit fullscreen mode

This kind of function can run in a batch job every hour or respond to events such as inventory changes. The important part is that every suggested price passes through hard business limits before it reaches the catalog.

Competitor data is useful, but it fails in boring ways

Competitor-based pricing sounds straightforward: collect competitor prices, compare them to yours, then adjust. In practice, the failures are mostly data quality problems.

You will see cases like:

  • The competitor page returns HTTP 429 and your scraper stores the previous price as if it were current
  • A marketplace shows a coupon-adjusted price in one location but not another
  • The product match is wrong, so your 256 GB phone gets compared with the 128 GB model
  • Availability is missing, so you match a price from an out-of-stock listing
  • Shipping is excluded, even though it changes the effective price

That is why freshness and provenance should be part of the pricing input. A price without seenAt, source, availability, and product match confidence should not carry the same weight as a verified current price.

If you need competitor prices as an input rather than building scrapers for every marketplace, Wire is one way to treat product price, availability, and promotion data as a feed your repricer can consume.

Even with good data, avoid blindly matching the lowest price. The lowest visible price may come from a seller with slow delivery, limited stock, or a temporary promotion. Your repricer should know when to ignore a competitor.

Prevent price flapping

Price flapping happens when a SKU changes too often because the inputs bounce around. For example, you undercut a competitor by 2 percent, they undercut you again, and both systems keep walking down until one hits a floor.

You can reduce this with a few practical controls:

CREATE TABLE price_changes (
  id BIGSERIAL PRIMARY KEY,
  sku TEXT NOT NULL,
  old_price NUMERIC(10, 2) NOT NULL,
  new_price NUMERIC(10, 2) NOT NULL,
  reason TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX price_changes_sku_created_at_idx
  ON price_changes (sku, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

Before applying a new price, check recent changes:

async function canChangePrice(db: any, sku: string, maxChangesPerDay: number) {
  const result = await db.query(
    `SELECT count(*)::int AS count
     FROM price_changes
     WHERE sku = $1
       AND created_at > now() - interval '24 hours'`,
    [sku]
  );

  return result.rows[0].count < maxChangesPerDay;
}
Enter fullscreen mode Exit fullscreen mode

This audit table also gives support and merchandising teams an answer when someone asks, “Why did this price change?” Store the rule name or model version in reason. Without that, debugging pricing behavior becomes guesswork.

Rule-based first, ML later

Machine learning can help with demand forecasting, elasticity estimates, and segment-level pricing. But many teams should start with rules because rules are easier to inspect.

A good first version might include:

  • Competitor price matching for known top SKUs
  • Inventory-based increases when stock is low
  • Discounts for aging inventory
  • Margin floors and maximum movement caps
  • Manual approval for high-value products

Once you have historical data, you can test whether a model improves outcomes. Do not just measure revenue. Track gross margin, conversion rate, units sold, refund rate, customer complaints, and price change frequency. A pricing model that increases revenue while crushing margin did not improve the business.

Roll it out like a risky production change

Dynamic pricing should ship behind controls. Start with a small SKU set. Run the repricer in shadow mode first, where it writes recommended prices but does not publish them. Compare recommendations against human decisions and existing rules.

For each recommendation, log:

  • Input prices and timestamps
  • Inventory level
  • Sales velocity
  • Rule or model version
  • Old price and recommended price
  • Final published price
  • Any constraint that modified the recommendation

Then review the outliers. The most useful test cases often come from rejected recommendations, not successful ones.

A practical next step: pick 20 SKUs, implement a rule-based repricer in shadow mode, and log every recommendation for two weeks before allowing it to publish prices automatically.

Source: dev.to

arrow_back Back to Tutorials