Module: Heatwave::Normalizers

Defined in:
app/lib/heatwave/normalizers.rb

Overview

Replacement for the normalizr gem. Provides a small set of named
normalization helpers that can be chained from a Rails 7.1 normalizes
lambda. The behaviour of each method intentionally mirrors the
corresponding add :name do |value| … end block that used to live in
config/initializers/normalizr.rb, so a mechanical migration of every
normalize :foo, with: %i[strip blank downcase] call site to
normalizes :foo, with: ->(v) { Heatwave::Normalizers.chain(v, :strip, :blank, :downcase) }
preserves behaviour exactly.

Two callsite-shape helpers:

  • Heatwave::Normalizers.default(v) — the global default chain
    (:strip, :blank) used
    when normalize was called
    without with:.
  • Heatwave::Normalizers.chain(v, *names, **options)
    — apply the named normalizers
    in order.
    Library code: normalizers. A flat registry of ~25 single-purpose normalizer
    methods; it legitimately exceeds the module-length budget.

Constant Summary collapse

DEFAULT_CHAIN =

Default chain.

%i[strip blank].freeze
TRUE_VALUES =

True values.

[true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON'].freeze
NULL_STRINGS =

Null strings.

['', '{}', '[]', 'null', 'nil'].freeze
CURRI_TRACKING_ID =

A Curri trackingId: "C-" plus exactly 30 base62 characters (four
observed live 2026-07-24/2026-08-06, all 30). The only case-SENSITIVE
tracking identifier we store, hence the carve-out in tracking_number.

Deliberately anchored and exact-length so it cannot swallow another
carrier's number: it needs a literal hyphen in position 3 and a total
length of 32, which nothing else we handle has — UPS is 1Z + 16, FedEx
and USPS are all digits, Canpar is 15, and LTL PRO numbers are 9-11
digits (XPO's hyphenated "285-141102" is 10).

/\AC-[A-Za-z0-9]{30}\z/
ASCII_PUNCTUATION_MAP =

Common Unicode punctuation that breaks ASCII-only protocols (e.g.
X12 EDI N3 Address Information segments). Mapped to their plain
ASCII equivalents so a downstream I18n.transliterate call doesn't
have to fall back to ?.

{
  "" => "'",  "" => "'",  "" => "'",  "" => "'",
  "" => '"',  "" => '"',  "" => '"',  "" => '"',
  "" => "'",  "" => '"',
  "" => '-',  "" => '-',  "" => '-', "" => '-',
  "" => '-',  "" => '-',  "" => '-',
  "" => '...',
  " " => ' ', "" => ' ', "" => '', "" => ''
}.freeze
ASCII_PUNCTUATION_REGEXP =

Alternation over every ASCII_PUNCTUATION_MAP key, so one gsub pass
replaces them all.

Regexp.union(ASCII_PUNCTUATION_MAP.keys).freeze
/[™®©℠℗ⒸⓇ]/
FEED_INCH_REGEXP =

Measurement feet/inch marks → retailer-friendly abbreviations (used by
feed_safe: 49′ → "49 ft.", 30″ → "30 in."). Inches first (double-prime ″,
two apostrophes '', or quote ") then feet (prime ′ or single apostrophe ').
Prime/double-prime always convert; the ambiguous ASCII '/" convert only when
NOT followed by a Unicode letter (\p{L}, so accented letters count too), e.g.
49' x → feet but possessives like 3's / 80's / 3'Élite stay untouched.

/(\d)\s*(?:″|(?:''|")(?!\p{L}))/
FEED_FOOT_REGEXP =

Feet half of the pair above: a digit followed by a prime (′) or an
ASCII apostrophe not followed by a letter, so 49' converts but
80's does not.

/(\d)\s*(?:′|'(?!\p{L}))/
YOUTUBE_ID_REGEXP =

A YouTube video ID is exactly 11 characters from [A-Za-z0-9_-]. Anything
else (a stripped leading char, a pasted watch URL, trailing junk) must be
rejected — a malformed id silently 404s every YouTube push/sync. Shared
by the youtube_id normalizer and Video's format validation.

/\A[A-Za-z0-9_-]{11}\z/
YOUTUBE_URL_ID_REGEXP =

Pull the 11-char id out of the common YouTube URL shapes: watch?v=,
youtu.be/, /embed/, /shorts/. The trailing boundary keeps a garbled
12+-char id segment from matching on its first 11 chars and being silently
accepted — that's the exact truncation this guard exists to reject.

%r{(?:youtu\.be/|/embed/|/shorts/|[?&]v=)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])}

Class Method Summary collapse

Class Method Details

.ascii_safe(value) ⇒ Object

Map common Unicode punctuation (smart quotes, primes, en/em dashes,
ellipsis, NBSP, ZWSP, BOM) to ASCII, then I18n.transliterate to
strip accents. Use for outbound payloads that must be ASCII-only,
e.g. X12 EDI N3 Address Information segments which reject "Anne’s".

Parameters:

  • value (Object)

    any value; only present Strings are altered

Returns:

  • (Object)

    the ASCII-only String, or the input unchanged



236
237
238
239
240
# File 'app/lib/heatwave/normalizers.rb', line 236

def ascii_safe(value)
  return value unless value.is_a?(String) && value.present?

  I18n.transliterate(value.gsub(ASCII_PUNCTUATION_REGEXP, ASCII_PUNCTUATION_MAP))
end

.blank(value) ⇒ Object?

Returns nil when the value is a String composed entirely of
whitespace (or empty); leaves non-strings alone.

Parameters:

  • value (Object)

    any value; only Strings are altered

Returns:

  • (Object, nil)

    nil for an all-whitespace String, else the input



144
145
146
147
148
# File 'app/lib/heatwave/normalizers.rb', line 144

def blank(value)
  return value unless value.is_a?(String)

  value.match?(/\A[[:space:]]*\z/) ? nil : value
end

.boolean(value) ⇒ Boolean?

Coerce a loose value to a boolean against TRUE_VALUES. A blank
String is nil (unknown) rather than false, so an empty form field
doesn't read as an explicit "no".

Parameters:

  • value (Object)

    any value

Returns:

  • (Boolean, nil)

    nil for a blank String, else whether the value
    is in TRUE_VALUES



301
302
303
304
305
# File 'app/lib/heatwave/normalizers.rb', line 301

def boolean(value)
  return nil if value.is_a?(String) && value.blank?

  TRUE_VALUES.include?(value)
end

.capitalize(value) ⇒ Object

Uppercase the first character and lowercase the rest, via
String#capitalize.

Parameters:

  • value (Object)

    any value; only Strings are altered

Returns:

  • (Object)

    the capitalized String, or the input unchanged



187
188
189
# File 'app/lib/heatwave/normalizers.rb', line 187

def capitalize(value)
  value.is_a?(String) ? value.capitalize : value
end

.chain(value, *names, **options) ⇒ Object, Array

Apply a sequence of named normalizers.

Arrays are normalized per-element (mirroring the legacy normalizr
gem's behaviour for array-typed attributes); resulting nil entries
are dropped so callers don't accidentally write nils into PG array
columns.

Examples:

Chain three no-argument normalizers

chain('  Foo  ', :strip, :blank, :downcase)  # => 'foo'

Pass options to one step

chain(' 12.5 ', :strip, :numeric, numeric: { precision: 2 })

Parameters:

  • value (Object, Array)

    the value to normalize; arrays are
    normalized element-wise

  • names (Array<Symbol>)

    normalizer method names, applied in order

  • options (Hash{Symbol => Hash})

    per-step options, keyed by
    normalizer name — only the named step receives its hash

Options Hash (**options):

  • :numeric (Hash)

    options forwarded to numeric
    (e.g. numeric: { precision: 2 })

Returns:

  • (Object, Array)

    the normalized value



118
119
120
121
122
123
124
125
126
# File 'app/lib/heatwave/normalizers.rb', line 118

def chain(value, *names, **options)
  return value.filter_map { |item| chain(item, *names, **options) } if value.is_a?(Array)

  names.inject(value) do |acc, name|
    method_name = name.to_sym
    opts = options[method_name]
    opts.is_a?(Hash) ? public_send(method_name, acc, **opts) : public_send(method_name, acc)
  end
end

.control_chars(value) ⇒ Object

Remove non-printing control characters, keeping whitespace (so
newlines and tabs survive).

Parameters:

  • value (Object)

    any value; only Strings are altered

Returns:

  • (Object)

    the String without control chars, or the input



213
214
215
# File 'app/lib/heatwave/normalizers.rb', line 213

def control_chars(value)
  value.is_a?(String) ? value.gsub(/[[:cntrl:]&&[^[:space:]]]/, '') : value
end

.currency(value) ⇒ Object

Strip everything but digits and the decimal point — turns "$1,299.00"
into "1299.00" for a numeric column.

Parameters:

  • value (Object)

    any value; only Strings are altered

Returns:

  • (Object)

    digits-and-dot String, or the input unchanged



338
339
340
# File 'app/lib/heatwave/normalizers.rb', line 338

def currency(value)
  value.is_a?(String) ? value.gsub(/[^0-9.]+/, '') : value
end

.date(value) ⇒ Date?

Coerce to a Date, parsing Strings and calling to_date on anything
that responds to it.

Parameters:

  • value (String, #to_date, Object)

    any value

Returns:

  • (Date, nil)

    the date, or nil when it can't be coerced

Raises:

  • (Date::Error)

    when a String is present but unparseable



403
404
405
406
407
408
# File 'app/lib/heatwave/normalizers.rb', line 403

def date(value)
  case value
  when String then Date.parse(value)
  else value.to_date if value.respond_to?(:to_date)
  end
end

.deep_ascii_safe(value) ⇒ Hash, ...

Recursively apply ascii_safe to every String leaf in a Hash/Array
structure. Hash keys are left alone.

Parameters:

  • value (Hash, Array, String, Object)

    structure to walk

Returns:

  • (Hash, Array, String, Object)

    same shape, String leaves
    converted to ASCII



248
249
250
251
252
253
254
255
# File 'app/lib/heatwave/normalizers.rb', line 248

def deep_ascii_safe(value)
  case value
  when Hash   then value.transform_values { |v| deep_ascii_safe(v) }
  when Array  then value.map { |v| deep_ascii_safe(v) }
  when String then ascii_safe(value)
  else value
  end
end

.default(value) ⇒ Object?

Apply the configured default chain ([:strip, :blank]).

Parameters:

  • value (Object)

    the value to normalize

Returns:

  • (Object, nil)

    stripped value, or nil when it was blank



95
96
97
# File 'app/lib/heatwave/normalizers.rb', line 95

def default(value)
  chain(value, *DEFAULT_CHAIN)
end

.downcase(value) ⇒ Object

Lowercase a present String; pass anything else through untouched.

Parameters:

  • value (Object)

    any value; only present Strings are altered

Returns:

  • (Object)

    the lowercased String, or the input unchanged



154
155
156
# File 'app/lib/heatwave/normalizers.rb', line 154

def downcase(value)
  value.is_a?(String) && value.present? ? value.downcase : value
end

.email(value) ⇒ String?

Extract the first well-formed email address from a String, so a
pasted "Name a@b.com" stores just the address.

Parameters:

  • value (Object)

    any value; only Strings are scanned

Returns:

  • (String, nil)

    the address, or nil when none is found



415
416
417
418
419
# File 'app/lib/heatwave/normalizers.rb', line 415

def email(value)
  return unless value.is_a?(String)

  value.scan(Truemail::RegexConstant::REGEX_EMAIL_PATTERN)&.first&.first&.strip&.presence
end

.feed_safe(value) ⇒ Object

The single shared cleanup for product names/titles emitted to external
retailers and product feeds (Google, OpenAI Ads, Wayfair, Amazon,
Walmart, Menard, …). Abbreviates measurement marks (feet ′ → "ft.",
inches ″ → "in."), normalizes common Unicode punctuation (smart quotes,
en/em dashes, NBSP, ZWSP, BOM) to ASCII, then strips legal/IP glyphs
(™ ® © ℠ ℗ Ⓒ Ⓡ) via strip_legal_marks.

Unlike ascii_safe it does NOT transliterate accents away, and it
preserves true measurement symbols (° ½ × µ …), so
"Crémaillère 1.5′ x 49′ TempZone™""Crémaillère 1.5 ft. x 49 ft. TempZone".
Feet/inch marks convert only when digit-adjacent, so possessives ("Anne's")
survive. Backs the retailer-facing CatalogItem#reported_name; idempotent,
so re-applying it downstream (e.g. in a feed presenter) is harmless.

Parameters:

  • value (Object)

    any value; only present Strings are altered

Returns:

  • (Object)

    the feed-safe String, or the input unchanged



287
288
289
290
291
292
# File 'app/lib/heatwave/normalizers.rb', line 287

def feed_safe(value)
  return value unless value.is_a?(String) && value.present?

  abbreviated = value.gsub(FEED_INCH_REGEXP, '\1 in.').gsub(FEED_FOOT_REGEXP, '\1 ft.')
  strip_legal_marks(abbreviated.gsub(ASCII_PUNCTUATION_REGEXP, ASCII_PUNCTUATION_MAP))
end

.hash_compactor(value) ⇒ Hash

Recursively drop null-ish entries from a Hash, so a JSONB column
stores {} rather than a tree of empty strings and nils.

Parameters:

  • value (Object)

    any value; non-Hashes yield {}

Returns:

  • (Hash)

    the compacted Hash, never nil



458
459
460
461
462
# File 'app/lib/heatwave/normalizers.rb', line 458

def hash_compactor(value)
  return {} unless value.is_a?(Hash)

  compact_hash(value) || {}
end

.html_scrubber(value) ⇒ String?

Clean and compress user-supplied HTML, then curl its quotes.

Parameters:

  • value (Object)

    any value; only present Strings are processed

Returns:

  • (String, nil)

    the scrubbed HTML, or nil when blank or not a
    String



358
359
360
361
362
363
364
365
# File 'app/lib/heatwave/normalizers.rb', line 358

def html_scrubber(value)
  return unless value.is_a?(String) && value.present?

  helper_class = Class.new { include Models::Utilities::Html }
  cleaned = helper_class.new.clean_and_compress_html(value)
  curled = Heatwave::TypographicQuotes.curl_html(cleaned)
  curled&.strip&.presence
end

.integer(value) ⇒ Integer?

Coerce to Integer, discarding non-digits from Strings first.

Parameters:

  • value (String, Numeric, Object)

    any value

Returns:

  • (Integer, nil)

    the integer, or nil for unsupported types



390
391
392
393
394
395
# File 'app/lib/heatwave/normalizers.rb', line 390

def integer(value)
  case value
  when String then value.scan(/[\d+.]+/).join.to_i
  when Numeric, Float then value.to_i
  end
end

.json_to_hash(value) ⇒ Hash, ...

Parse a JSON String into a Hash/Array, passing already-parsed
structures through. Malformed JSON yields nil rather than raising —
these values arrive from third-party payloads and a parse failure
should null the column, not abort the write.

Parameters:

  • value (String, Hash, Array, nil)

    raw JSON or a parsed structure

Returns:

  • (Hash, Array, nil)

    the parsed structure, or nil when blank,
    unparseable, or not a String



715
716
717
718
719
720
721
722
723
724
725
# File 'app/lib/heatwave/normalizers.rb', line 715

def json_to_hash(value)
  return nil if value.blank?
  return value if value.is_a?(Array) || value.is_a?(Hash)
  return nil unless value.is_a?(String)

  begin
    JSON.parse(value)
  rescue JSON::ParserError
    nil
  end
end

.new_lines(value) ⇒ String?

Normalize Windows CRLF line endings to bare LF.

Parameters:

  • value (String, nil)

    any value responding to presence

Returns:

  • (String, nil)

    the LF-normalized String, or nil when blank



521
522
523
# File 'app/lib/heatwave/normalizers.rb', line 521

def new_lines(value)
  value.presence&.gsub("\r\n", "\n")
end

.no_undefined(value) ⇒ Object?

Null out the literal strings "undefined" / "null" / "nil", which
arrive from JavaScript clients and third-party feeds as real text.

Parameters:

  • value (Object)

    any value

Returns:

  • (Object, nil)

    nil for those three literals, else the input



347
348
349
350
351
# File 'app/lib/heatwave/normalizers.rb', line 347

def no_undefined(value)
  return nil if value.is_a?(String) && %w[undefined null nil].include?(value)

  value
end

.nullify_zero(value) ⇒ Object?

Treat a zero as "not set" — for columns where 0 and NULL mean the
same thing and NULL is the honest one.

Parameters:

  • value (Object)

    any value responding to to_i

Returns:

  • (Object, nil)

    nil when the value is zero, else the input



469
470
471
# File 'app/lib/heatwave/normalizers.rb', line 469

def nullify_zero(value)
  value.to_i.zero? ? nil : value
end

.numeric(value, precision: 0, strip_zeros: true, ceil: false) ⇒ String?

Pull the digits out of a loose value and re-render them at a fixed
precision. Non-numeric characters are discarded before parsing, so
"approx. 12.5 lb" becomes "12.5".

Parameters:

  • value (Object)

    any value; blank yields nil

  • precision (Integer) (defaults to: 0)

    decimal places in the result

  • strip_zeros (Boolean) (defaults to: true)

    drop insignificant trailing zeros

  • ceil (Boolean) (defaults to: false)

    round up to precision instead of nearest

Returns:

  • (String, nil)

    the formatted number, or nil when blank



376
377
378
379
380
381
382
383
384
# File 'app/lib/heatwave/normalizers.rb', line 376

def numeric(value, precision: 0, strip_zeros: true, ceil: false)
  return if value.blank?

  helper = Class.new { include ActionView::Helpers::NumberHelper }.new
  digit_string = value.to_s.scan(/[\d+.]+/).join
  big_decimal = BigDecimal(digit_string)
  big_decimal = big_decimal.ceil(precision) if ceil
  helper.number_with_precision(big_decimal, strip_insignificant_zeros: strip_zeros, precision: precision)
end

.parameterize(value) ⇒ Object

Convert a String to a URL-safe slug, via String#parameterize.

Parameters:

  • value (Object)

    any value; only Strings are altered

Returns:

  • (Object)

    the parameterized String, or the input unchanged



178
179
180
# File 'app/lib/heatwave/normalizers.rb', line 178

def parameterize(value)
  value.is_a?(String) ? value.parameterize : value
end

.phone(value, country_iso: nil) ⇒ String?

Parse and re-format a phone number to E.164-ish canonical form.

Parameters:

  • value (Object)

    any value; only present Strings are parsed

  • country_iso (String, nil) (defaults to: nil)

    two-letter country hint for
    ambiguous national-format numbers

Returns:

  • (String, nil)

    the formatted number, or nil when unparseable



427
428
429
430
431
432
# File 'app/lib/heatwave/normalizers.rb', line 427

def phone(value, country_iso: nil)
  return unless value.is_a?(String) && value.present?

  cleaned = value.gsub(/\P{ASCII}/u, '').strip.downcase
  PhoneNumber.parse_and_format(cleaned, country_iso: country_iso)
end

.resolve_shipping_carrier(carrier, tracking_number: nil) ⇒ String, Object

Chains the name-based shipping_carrier normalizer with the
tracking-number-format fallback shipping_carrier_from_tracking_number.
Use this anywhere you need a best-effort canonical carrier name
from a (possibly garbage) shipments.carrier value plus an
optional tracking number. Mirrors the resolution rule in
WyShipping.class_for_carrier.

Examples:

Manual entry where carrier is a placeholder but the

tracking number is recoverable
resolve_shipping_carrier('Override', tracking_number: '1Z999AA10123456784')
# => 'UPS'

Parameters:

  • carrier (String, nil)

    raw shipments.carrier value

  • tracking_number (String, nil) (defaults to: nil)

    optional fallback used only
    when name-based normalization leaves the input unchanged

Returns:

  • (String, Object)

    canonical carrier name or the original
    input when neither rule matches



699
700
701
702
703
704
705
# File 'app/lib/heatwave/normalizers.rb', line 699

def resolve_shipping_carrier(carrier, tracking_number: nil)
  normalized = shipping_carrier(carrier)
  return normalized unless tracking_number.present? && normalized == carrier

  sniffed = shipping_carrier_from_tracking_number(tracking_number)
  sniffed || normalized
end

.shipping_carrier(value) ⇒ String, Object

Map a free-form shipments.carrier value to a canonical
WyShipping.class_for_carrier key (matching the Shipping::<Name>
class name). Returns the input unchanged when no rule matches, so
unfamiliar carrier strings keep surfacing NameError instead of
being silently mis-mapped.

Covers UPS / FedEx / Canpar / Purolator / USPS / Canada Post
service-level and case-variant strings observed in production
(e.g. "Ups Standard", "FedEx Ground®", "Canpar Express",
"fedex_international_ground", "PUROLATOR_GROUND",
"Canada Post Expedited Parcel"). Carriers that already
constantize cleanly (FedEx, UPS, USPS, Canpar, Canadapost,
Purolator, AmazonSeller, WalmartSeller, SpeedeeDelivery,
Freightquote, RlCarriers, DPD, GLS, DhlExpress) are returned
untouched.

Amazon Logistics variants — "Amzl", "AMZL", "AMZL_CA_PREMIUM",
"Amazon Ground", "Amazon Shipping Ground" — canonicalize to
"Amazon Shipping", which is connected on the ShipEngine account
as carrier_code amazon_shipping_us (tracking-only,
carrier_id se-6604086). Rates/labels still go through
Shipping::AmazonSeller (SP-API direct), but tracking webhook
subscription is handled by SE just like every other parcel
carrier.

Bare "Amazon" is intentionally left alone — too ambiguous, could
be the parent corp / a marketplace name / a service. Same for
"AmazonSeller" — that's the marketplace flow where the
underlying carrier varies per shipment (USPS, FedEx, UPS, or
Amazon Logistics depending on Buy Shipping's choice). The
tracking-number sniffer chain in resolve_shipping_carrier
recovers the right carrier from the number's format.

Also leaves placeholder/free-text values alone ("Standard",
"Override", "Shipping override, please confirm", "Warehouse
Pickup", "AMJM").

Parameters:

  • value (String, nil)

    raw shipments.carrier string

Returns:

  • (String, Object)

    canonical carrier name, or the input
    value untouched



593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
# File 'app/lib/heatwave/normalizers.rb', line 593

def shipping_carrier(value)
  return value unless value.is_a?(String)

  stripped = value.strip
  return value if stripped.empty?

  cleaned = stripped.gsub(/[®™]/, '').sub(/\*+\z/, '').squeeze(' ').strip.downcase

  case cleaned
  when 'legacyfedex'                                              then 'LegacyFedEx'
  when /\Afed[\s_-]*ex[\s_-]*freight\z/                           then 'FedExFreight'
  when /\Afed[\s_-]?ex/, 'fdx'                                    then 'FedEx'
  when 'ups', /\Aups[\s_-]/                                       then 'UPS'
  when /\Acanpar\b/                                               then 'Canpar'
  when /\Apurolator/                                              then 'Purolator'
  when /\Ausps/, 'united states postal service'                   then 'USPS'
  when /\Acanadapost/, /\Acanada\s*post/                          then 'Canadapost'
  when 'dhl', /\Adhl[\s_-]*express\b/, /\Adhl[\s_-]*express\s+(international|intl)/ then 'DhlExpress'
  when 'amzl', /\Aamzl[\s_-]/, /\Aamazon[\s_-]*ground\z/,
       /\Aamazon[\s_-]+shipping\b/ then 'Amazon Shipping'
  else value
  end
end

.shipping_carrier_from_tracking_number(value) ⇒ String?

Infer the canonical carrier name from a tracking number's format
(and checksum, when the carrier defines one). Defers to
TrackingNumber, which owns the barcode-spec families
for every carrier we care about — UPS (1Z + mod-10), FedEx
(Express 12-digit, Ground 15-digit, SmartPost/Ground Economy
20/22-digit, 96-prefix), USPS (13-char international AA######US,
IMpb 22-digit, Express Mail, Certified, etc.), Canada Post
(16-digit domestic, AA######CA international), DHL Express
(10/JJD-prefix), Canpar (D-prefix), Purolator (PIN + variants),
Spee-Dee, R+L Carriers. Mod-10 checksum validation makes false
positives rare on the families that have a check digit (UPS,
USPS IMpb, FedEx Ground, Canada Post).

Used as a fallback by WyShipping.class_for_carrier and
resolve_shipping_carrier when the name-based
shipping_carrier normalizer can't recognize the carrier
string but a tracking number is present — covers placeholder
cases like "Override" / "Shipping override, please confirm" /
"Standard" when the carrier is discoverable from the number.

Two-pass:

  1. TrackingNumber.parse — preferred, validates
    format + checksum.
  2. Regex fallback for the two patterns we'd accept even with
    a bad checksum (typo'd UPS / USPS international where
    registering is still better than skipping).

Returns nil only when neither the catalog nor the fallback regex
matches.

Parameters:

  • value (String, nil)

    tracking number

Returns:

  • (String, nil)

    canonical carrier name or nil when no
    pattern matches



650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
# File 'app/lib/heatwave/normalizers.rb', line 650

def shipping_carrier_from_tracking_number(value)
  return nil unless value.is_a?(String)

  s = value.strip.gsub(/\s+/, '').upcase
  return nil if s.empty?

  # Some real Purolator 520-prefix PINs also satisfy FedEx's checksum, and
  # the catalog prefers FedEx when scores tie (fedex.json loads first).
  # Prefer Purolator only when its own format and Luhn checksum both validate.
  if s.match?(/\A520\d{9}\z/) &&
     Heatwave::TrackingNumber.detect_all(s).any? { |match| match.courier_code == :purolator }
    return 'Purolator'
  end

  tn = Heatwave::TrackingNumber.parse(s)
  if tn.valid? && (internal = PARCEL_TRACKING_NUMBER_GEM_TO_INTERNAL[tn.courier_code])
    return internal
  end

  # Format-only fallback for checksum failures. Deliberately not a
  # catalog format-match: 12-digit FedEx / Purolator / DHL and 22-digit
  # USPS IMpb collide, and a bad check digit on those must stay nil
  # (see the normalizers tests). UPS 1Z, USPS S10, Canpar, and Spee-Dee
  # are distinctive enough that registering a slightly-wrong number is
  # better than skipping — ShipEngine re-validates.
  case s
  when /\A1Z[0-9A-Z]{16}\z/ then 'UPS'
  when /\A[A-Z]{2}\d{9}US\z/ then 'USPS'
  when /\AD[A-Z0-9]{18,21}\z/ then 'Canpar'
  when /\ASP\d{18}\z/, /\ASP[0-9A-Z]+SD\d+\z/ then 'SpeedeeDelivery'
  end
end

.squish(value) ⇒ Object

Collapse runs of whitespace and trim, via String#squish.

Parameters:

  • value (Object)

    any value; only Strings are altered

Returns:

  • (Object)

    the squished String, or the input unchanged



170
171
172
# File 'app/lib/heatwave/normalizers.rb', line 170

def squish(value)
  value.is_a?(String) ? value.squish : value
end

.strip(value) ⇒ Object

Strip leading/trailing whitespace from a String; pass anything else
through untouched.

Parameters:

  • value (Object)

    any value; only Strings are altered

Returns:

  • (Object)

    the stripped String, or the input unchanged



135
136
137
# File 'app/lib/heatwave/normalizers.rb', line 135

def strip(value)
  value.is_a?(String) ? value.strip : value
end

Strip legal / IP glyphs (™ ® © ℠ ℗ Ⓒ Ⓡ) and collapse any double space the
removal leaves. Unlike ascii_safe/transliterate, this touches ONLY
those marks — measurement symbols (″ ′ ° ½ × µ …) and accents are preserved.
The primitive behind feed_safe; call it directly only when smart-quote
normalization is unwanted.

Parameters:

  • value (Object)

    any value; only present Strings are altered

Returns:

  • (Object)

    the String without legal marks, or the input



265
266
267
268
269
# File 'app/lib/heatwave/normalizers.rb', line 265

def strip_legal_marks(value)
  return value unless value.is_a?(String) && value.present?

  value.gsub(LEGAL_MARK_REGEXP, '').gsub(/[ \t]{2,}/, ' ').strip
end

.tagify(value) ⇒ String?

Convert a String to a dasherized, lowercase tag slug.

Parameters:

  • value (Object)

    any value; non-Strings yield nil

Returns:

  • (String, nil)

    the tag slug, or nil when blank or not a String



477
478
479
# File 'app/lib/heatwave/normalizers.rb', line 477

def tagify(value)
  value.to_s.squish.parameterize.dasherize.downcase.presence if value.is_a?(String)
end

.titleize(value) ⇒ String?

Squish and title-case a String.

Parameters:

  • value (Object)

    any value; non-Strings yield nil

Returns:

  • (String, nil)

    the title-cased String, or nil when blank or
    not a String



314
315
316
# File 'app/lib/heatwave/normalizers.rb', line 314

def titleize(value)
  value.to_s.squish.titleize.presence if value.is_a?(String)
end

.tracking_number(value) ⇒ String?

Tracking numbers carry no case or whitespace semantics: carriers
print them in readable groups ("3822 5195 7215") and ShipEngine
returns them packed and uppercased. Store the packed form so a
hand-keyed number matches the webhook's, the carrier tracking URL
resolves, and TrackingNumberFormatValidator sees the same string
the carrier's barcode spec describes.

Only whitespace is removed — dashes are left alone because some
LTL PRO numbers are issued with them.

ONE EXCEPTION: Curri trackingIds. Every other carrier's number is
digits or already-uppercase, so "no case semantics" holds — Curri's
is base62 and case-SENSITIVE. Upcasing one yields Curri's not-found
page, byte-identical to a made-up id (verified against
app.curri.com/track 2026-08-06), so the CRM tracking link and any
number a human copies out of the CRM both break. See
CURRI_TRACKING_ID for why the pattern can't collide.

Parameters:

  • value (String, nil)

Returns:

  • (String, nil)

    packed number, uppercased unless it is a Curri
    trackingId; nil when blank



546
547
548
549
550
551
# File 'app/lib/heatwave/normalizers.rb', line 546

def tracking_number(value)
  return value unless value.is_a?(String)

  packed = value.gsub(/[[:space:]]/, '')
  blank(CURRI_TRACKING_ID.match?(packed) ? packed : upcase(packed))
end

.truncate(value, length: 30, omission: '...') ⇒ Object

Shorten a String to length characters, appending omission when it
had to cut. The omission counts toward the limit, so the result never
exceeds length.

Parameters:

  • value (Object)

    any value; only Strings are altered

  • length (Integer) (defaults to: 30)

    maximum length of the result

  • omission (String) (defaults to: '...')

    suffix appended when truncation occurred

Returns:

  • (Object)

    the truncated String, or the input unchanged



326
327
328
329
330
331
# File 'app/lib/heatwave/normalizers.rb', line 326

def truncate(value, length: 30, omission: '...')
  return value unless value.is_a?(String)

  cutoff = length - omission.length
  value.length > length ? value[0...cutoff] + omission : value
end

.unit_symbolizer(value) ⇒ Object

Replaces ' and " with prime symbols when adjacent to digits.
4' → 4′ | 6" → 6″

Parameters:

  • value (Object)

    any value; only Strings containing a digit are
    altered

Returns:

  • (Object)

    the String with prime symbols, or the input unchanged



487
488
489
490
491
# File 'app/lib/heatwave/normalizers.rb', line 487

def unit_symbolizer(value)
  return value unless value.is_a?(String) && /\d+/.match?(value)

  value.gsub(/(\d+)\s?'/, "\\1′").gsub(/(\d+)\s?"/, "\\1″")
end

.unitizer(value) ⇒ String?

Best-effort length normalizer: tolerates fractions, primes, and
the plain feet/inches notation customers paste in.

Parameters:

  • value (Object)

    any value; only present Strings are parsed

Returns:

  • (String, nil)

    the length in feet to two decimals (e.g.
    "4.50 ft"), or nil when the input can't be read as a length



499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# File 'app/lib/heatwave/normalizers.rb', line 499

def unitizer(value)
  return unless value.is_a?(String) && value.present?

  value = value.gsub("''", '"')
  ['.5', '.25', '.125', '.0625', '1/2', '1/4', '1/8', '1/16', '-', '+', ' ',
   '1/2', '1/4', '1/8', '1/16'].each { |s| value.gsub!(s, '') }
  value = "#{value} ft" unless value.match(/^\d+(\.\d+)?$/).nil?
  if value.index("'") && value.index('"').nil? && value.index('ft').nil?
    value = "#{value}\"" unless value.last == "'"
    value = "#{value} 0\"" if value.last == "'"
  end
  begin
    (RubyUnits::Unit.new(value) >> 'ft').to_s('%0.2f')
  rescue StandardError
    nil
  end
end

.upcase(value) ⇒ Object

Uppercase a present String; pass anything else through untouched.

Parameters:

  • value (Object)

    any value; only present Strings are altered

Returns:

  • (Object)

    the uppercased String, or the input unchanged



162
163
164
# File 'app/lib/heatwave/normalizers.rb', line 162

def upcase(value)
  value.is_a?(String) && value.present? ? value.upcase : value
end

.whitespace(value) ⇒ Object

Collapse horizontal whitespace to single spaces and tidy the
whitespace around newlines, preserving line structure (unlike
squish, which flattens everything to one line).

Parameters:

  • value (Object)

    any value; only Strings are altered

Returns:

  • (Object)

    the cleaned String, or the input unchanged



223
224
225
226
227
# File 'app/lib/heatwave/normalizers.rb', line 223

def whitespace(value)
  return value unless value.is_a?(String)

  value.gsub(/[^\S\n]+/, ' ').gsub(/\s?\n\s?/, "\n").strip
end

.youtube_id(value) ⇒ Object

Coerce a YouTube video-id input into the bare 11-char id. Accepts a
bare id as-is, or extracts it from a watch / youtu.be / embed / shorts
URL. Leaves anything it can't confidently parse untouched, so the
model's format validation rejects it instead of silently storing junk.

Parameters:

  • value (Object)

    a bare id or a YouTube URL; only Strings parse

Returns:

  • (Object)

    the 11-char id, or the trimmed input when unparseable



198
199
200
201
202
203
204
205
206
# File 'app/lib/heatwave/normalizers.rb', line 198

def youtube_id(value)
  return value unless value.is_a?(String)

  trimmed = value.strip
  return trimmed if trimmed.match?(YOUTUBE_ID_REGEXP)

  match = trimmed.match(YOUTUBE_URL_ID_REGEXP)
  match ? match[1] : trimmed
end

.zip_or_postal_code(value, country_iso: nil) ⇒ String?

Format a postal code for its country. Without a country_iso hint,
tries Canada first, then the US, so "K1A0B1" formats as "K1A 0B1".

Parameters:

  • value (Object)

    any value; blank yields nil

  • country_iso (String, nil) (defaults to: nil)

    two-letter country code

Returns:

  • (String, nil)

    the formatted code, or nil when it matches
    neither country



441
442
443
444
445
446
447
448
449
450
451
# File 'app/lib/heatwave/normalizers.rb', line 441

def zip_or_postal_code(value, country_iso: nil)
  return if value.blank?

  if country_iso
    ValidatesZipcode.format(value, country_iso)
  elsif ValidatesZipcode.valid?(value, 'CA')
    ValidatesZipcode.format(value, 'CA')
  elsif ValidatesZipcode.valid?(value, 'US')
    ValidatesZipcode.format(value, 'US')
  end
end