Skip to content

Menu

Normalize prices without losing currency

Preserve raw prices and currency while producing exact decimal strings with explicit format policies, runnable Node.js code and ambiguity checks.

By Scraipe editorial. Checked 2026-10-19.

Treat amount and currency as one value

Normalize a price only after identifying its currency and number format from the source context. Keep the original display text, produce an exact amount with its currency code, and send ambiguous values for review. Removing every nonnumeric character is not a reliable conversion: AUD 1,234.50 and 1.234,50 € use different separators, and $ alone does not establish a currency.

This guide includes a small Node.js normalizer with fourteen tests. It uses invented local fixtures, requires no packages or API key, and was tested with Node 22.23.2 on 19 October 2026. It converts a limited set of explicitly configured price displays into decimal and minor-unit strings. It does not exchange currencies, infer tax, select an offer or perform payment processing.

Capture the evidence before changing the value

Retain at least the displayed priceText, source URL, capture time, product/variant identity and the evidence for the selected currency. Currency evidence might be an explicit code on the offer or a verified source-specific policy. A site suffix, browser language or dollar symbol alone is insufficient. If structured data has price and priceCurrency, compare them with the same visible offer; do not combine one variant's amount with another variant's currency.

Schema.org documents a separate priceCurrency field. For the fiat currencies in this example, use uppercase codes such as AUD and EUR. Record the currency policy outside untrusted scraped values, and revisit it when the store changes its selected market. The example's policy field is caller-supplied configuration, not something its parser discovers or verifies.

Input situationDecision
AUD 1,234.50, verified AUD/comma-group policyPreserve raw text; normalize to 1234.50 AUD
1.234,50 €, verified EUR/comma-decimal policyNormalize to 1234.50 EUR
$12.50, currency not establishedReview; do not silently assume USD
Missing price represented by explicit nullRetain missing; do not turn it into zero
Observed $0, established USD policyNormalize to zero USD
from $12, a range, multiple prices or a monthly labelReview the offer semantics before normalization

A missing field and explicit null remain different. The batch example rejects a row without priceText; null means the caller recorded an unavailable price. Neither proves why the price is absent or whether a capture finished loading.

Use a narrow format policy

The downloadable module supports two syntax profiles, not every format used by a locale:

  • comma-dot: optional groups of three digits separated by commas, followed by an optional decimal point and fraction.
  • dot-comma: optional groups of three digits separated by periods, followed by an optional comma and fraction.

Both allow an ungrouped integer, ASCII digits, and one matching currency code or symbol at either end. AUD and USD use $, EUR uses €, and JPY uses ¥ in this teaching policy. A matching symbol is accepted only because the caller has already selected the currency. A conflicting code or extra marker is rejected. Whitespace around the amount is trimmed; spaces inside the number, Indian grouping, full-width digits, negative amounts, exponents and accounting parentheses are deliberately unsupported.

The bounded currency table uses two fraction digits for AUD/USD/EUR and zero for JPY. It is not a complete currency database or a payment-provider policy. Adding currencies requires reviewed currency precision, formatting rules and tests. Fractional digits beyond the configured scale are rejected, including a decimal fraction on JPY; the example never silently rounds. Cash rounding and provider-specific submission requirements are separate rules.

With a comma-group profile, 1,234 means one thousand two hundred thirty-four. With a comma-decimal profile it has three fractional digits and this two-decimal EUR policy rejects it. The parser cannot detect a wrongly supplied but syntactically plausible profile. Verify the policy from the source before relying on the output.

Run the complete example

Download these three files into a fresh directory:

Use Node 22 or later; no npm install is needed:

bash

node --test normalize-price.test.mjs
node normalize-price.mjs prices.fixture.json

Expect fourteen passing tests and a summary of four normalized prices, one missing price and zero review cases. One input row is:

json

{
  "priceText": "AUD 1,234.50",
  "policy": { "currency": "AUD", "format": "comma-dot" }
}

Its result includes:

json

{
  "raw": "AUD 1,234.50",
  "currency": "AUD",
  "format": "comma-dot",
  "policyVersion": "price-normalization-v1",
  "status": "normalized",
  "amountDecimal": "1234.50",
  "amountMinor": "123450"
}

The full output also includes a row index and a reason field. Indexes locate input rows; they are not product identifiers. Keep your source product keys alongside the result. Do not import every output row indiscriminately: review and missing have null amounts, and your consumer must define what happens to each.

The CLI exits 0 when there are no review rows, even if a price is missing; 1 when at least one row needs review; and 2 for invalid input structure, unreadable files or malformed JSON. It prints the report for exit 1 so callers can inspect the rejected values. A nonzero exit is not permission to discard those rows and call the remaining batch complete. The tests exercise all three exit paths, missing versus zero, incompatible markers, malformed grouping, ranges, precision and exact large amounts.

Preserve precision through storage

The module builds the minor-unit amount with BigInt after validating the entire string. It returns strings so ordinary JSON serialization works and large amounts do not lose integer precision. For example, 9007199254740993.01 becomes the exact minor-unit string 900719925474099301 under the two-decimal policy. This is a precision test, not a plausible observed product price.

Keep these strings with the currency or convert them into a deliberately sized decimal/integer storage type. Do not immediately call Number on the result and undo the precision guarantee. Sum only compatible currencies and scales. An exchange-rate workflow needs a separate rate source, timestamp and rounding policy; this example has none.

parseFloat is also unsuitable as a validation step: it accepts a numeric prefix and ignores trailing invalid text. For example, parseFloat("12.50/month") returns 12.5. A formatter such as Intl.NumberFormat helps display an already-understood amount; its formatting methods are not a general parser for unknown price strings. Formatting can round, so preserve the unrounded canonical amount independently of its presentation.

Keep raw price text in Scraipe extraction

Use a string field such as priceText in your product schema, and verify that the returned text still contains the expected currency and separators. An instruction in a field description does not establish a complete price-normalization contract. Keep parent/variant associations intact, as demonstrated in the nested-list guide.

The current compiled selector interpreter has a generic toNumber transform. A local source probe returned 1234.5 for AUD 1,234.50, but 1.2345 for 1.234,50 € and 1250 for 12,50 €. The transform removes commas and extracts a numeric portion; it does not provide currency-aware parsing. The same fixture kept 1.234,50 € intact when extracted as text without that transform. These are local source-code observations, not claims about a completed hosted request or every model-generated extractor.

Before normalizing a real result, check the API response's success, truncation and continuation metadata, then compare representative values with the source. A well-formed amount cannot establish that the correct offer was captured. Use the API reference and request receipt guide when inspecting extraction and charges. The local example makes no network calls and consumes no Scraipe credits; fetching, compilation and repair through Scraipe can incur charges.

Route failures to investigation

The example caps a batch at 1,000 rows, each price string at 100 characters and the integer portion at 18 digits. Its CLI rejects files above 1 MiB after reading them into memory. These are fixture safeguards, not streaming or hostile-input isolation. JSON duplicate member names are not detected. Adapt resource controls before using it as a production importer.

A review result should retain the raw value, policy version and reason so you can correct the source policy or extraction, then rerun deliberately. Do not broaden the parser until a suspicious value fits. Sale versus list price, unit quantities, tax inclusion, shipping, subscription periods and mixed currencies require distinct fields and business rules. An exact decimal is useful only when it still describes the right offer.

Sources and verification

Try the next step

Try the playground

Continue building