Implementing price matching without turning it into a pricing bug

typescript dev.to

Price matching sounds like a simple business rule until someone asks you to implement it. Then you find out that cheaper competitor prices can be stale, out of stock, for a different SKU, locked behind coupons, or low enough to make your own order unprofitable.

If you treat price matching as if competitor.price < our.price then match, you will ship a margin bug.

Model the policy before writing the scraper

A price match policy is not just a discount. It is an eligibility decision.

At minimum, you need to answer these questions:

  • Is this the same product?
  • Is the competitor eligible?
  • Is the competitor offer currently available?
  • Does the price include shipping, tax, coupons, bundles, or membership discounts?
  • Is the matched price still above your margin floor?
  • Is this before purchase, at checkout, or after purchase as a refund?

Those details should live in data and code, not in customer support notes.

A basic schema might look like this:

create table competitor_offers (
  id bigserial primary key,
  product_id bigint not null,
  competitor text not null,
  competitor_url text not null,
  price_cents integer not null,
  currency char(3) not null,
  in_stock boolean not null,
  observed_at timestamptz not null,
  evidence jsonb not null default '{}'
);

create index competitor_offers_lookup
on competitor_offers (product_id, competitor, observed_at desc);
Enter fullscreen mode Exit fullscreen mode

The evidence column is useful. Store things like page title, detected SKU, shipping text, and screenshot URL if you capture one. When a customer asks why a match was rejected, you do not want to reverse-engineer yesterday's page state from logs.

Treat competitor prices as untrusted input

Competitor price data usually comes from scraping, feeds, marketplace APIs, or customer-submitted URLs. All of those fail in boring ways.

A scraper may read the wrong DOM node after a layout change. A marketplace listing may show a used item next to a new one. A customer may submit a regional price you cannot verify from your server. A competitor page may return 200 OK while rendering an out-of-stock message in client-side JavaScript.

The symptom is not always an exception. Sometimes the job succeeds and gives you bad data.

That is why the extraction layer should produce structured records with confidence signals, not just a number. If you do not want to maintain per-retailer extraction, retries, and price evidence yourself, Wire is one option for turning competitor product pages into structured price records with failure handling.

Even if you build the whole pipeline yourself, keep the matching decision separate from the extraction decision. A scraped price should not automatically become a customer discount.

Encode rejection reasons explicitly

Here is a small TypeScript version of the core rule. It returns a decision with reasons instead of throwing or silently declining.

type Offer = {
  productId: number;
  competitor: string;
  priceCents: number;
  currency: 'USD' | 'EUR' | 'GBP';
  inStock: boolean;
  observedAt: Date;
};

type Product = {
  id: number;
  currentPriceCents: number;
  costCents: number;
  currency: 'USD' | 'EUR' | 'GBP';
};

type Policy = {
  eligibleCompetitors: Set<string>;
  maxOfferAgeMinutes: number;
  minMarginPercent: number;
};

type Decision =
  | { eligible: true; matchedPriceCents: number; marginPercent: number }
  | { eligible: false; reasons: string[] };

function evaluatePriceMatch(
  product: Product,
  offer: Offer,
  policy: Policy,
  now = new Date()
): Decision {
  const reasons: string[] = [];

  if (offer.productId !== product.id) reasons.push('different_product');
  if (!policy.eligibleCompetitors.has(offer.competitor)) reasons.push('competitor_not_eligible');
  if (offer.currency !== product.currency) reasons.push('currency_mismatch');
  if (!offer.inStock) reasons.push('competitor_out_of_stock');

  const ageMinutes = (now.getTime() - offer.observedAt.getTime()) / 60_000;
  if (ageMinutes > policy.maxOfferAgeMinutes) reasons.push('offer_too_old');

  if (offer.priceCents >= product.currentPriceCents) reasons.push('not_cheaper');

  const marginPercent = ((offer.priceCents - product.costCents) / offer.priceCents) * 100;
  if (marginPercent < policy.minMarginPercent) reasons.push('below_margin_floor');

  if (reasons.length > 0) {
    return { eligible: false, reasons };
  }

  return {
    eligible: true,
    matchedPriceCents: offer.priceCents,
    marginPercent: Math.round(marginPercent * 100) / 100
  };
}
Enter fullscreen mode Exit fullscreen mode

This makes failures visible:

{"eligible":false,"reasons":["offer_too_old","below_margin_floor"]}
Enter fullscreen mode Exit fullscreen mode

That response is much easier to debug than a generic PRICE_MATCH_DENIED error.

Post-purchase matching is a different workflow

Pre-purchase price matching changes the cart price. Post-purchase matching creates a refund or credit. Do not implement both with the same code path.

For post-purchase requests, add constraints like:

  • purchase must be within 7 or 30 days
  • refund cannot exceed amount paid
  • one price adjustment per order line
  • original promotions must be included in the comparison
  • refund must be idempotent

The idempotency part matters. If a customer retries a request after a timeout, your API should not issue two refunds.

create unique index one_price_adjustment_per_order_line
on price_adjustments (order_line_id)
where reason = 'price_match';
Enter fullscreen mode Exit fullscreen mode

You can still return a friendly message to the customer, but the database should enforce the business rule.

Avoid matching every price movement

The dangerous version of price matching is reacting to every competitor discount. Flash sales, clearance items, regional tests, and marketplace seller mistakes can all pull your price down for no good reason.

A safer system lets pricing teams configure boundaries:

  • match only selected competitors
  • ignore offers older than a set threshold
  • exclude marketplace sellers below a rating threshold
  • require the competitor item to be in stock
  • preserve a minimum gross margin
  • exclude categories where brand positioning matters more than price

Those rules are not just business preferences. They are production safeguards.

A practical next step

If you are building price matching, start by logging decisions without applying discounts. Run the evaluator in shadow mode for a week, store the accepted and rejected matches, and review the rejection reasons with support and pricing teams.

You will find bad product mappings, stale competitor offers, and margin edge cases before customers do.

Source: dev.to

arrow_back Back to Tutorials