MerchantIcons

Hex.pm Docs

Turn a noisy merchant description into a known merchant and its icon. Offline, stateless and safe by design.

iex> {:ok, merchant} = MerchantIcons.resolve("DL * GOOGLE A0000033333")
iex> {merchant.id, merchant.name}
{"google", "Google"}

Overview

Bank and card statements describe merchants in messy ways: processor prefixes, codes, numbers, statuses, random casing and accents.

DL * GOOGLE A0000033333
Google A0000033333
Uber UBER * PENDING
DL * UberRides
ADOBE

MerchantIcons.resolve/1 takes one of these descriptions and:

  1. validates and normalizes it (Unicode NFKD, accents removed, case folded, zero-width characters removed);
  2. splits it into tokens and ignores a known processor prefix;
  3. matches it against a small static dataset of merchants;
  4. returns {:ok, %MerchantIcons.Merchant{}} with the merchant id, name and, when available, the complete SVG markup of its icon, or {:ok, :unknown} when the merchant is not in the dataset.

What it deliberately is not:

Installation

Requires Elixir ~> 1.20. The only dependency is :telemetry.

Add merchant_icons to your dependencies in mix.exs:

def deps do
[
{:merchant_icons, "~> 0.3.1"}
]
end

Usage

case MerchantIcons.resolve("Uber UBER * PENDING") do
{:ok, :unknown} ->
# valid description, merchant not in the dataset
:unknown
{:ok, %MerchantIcons.Merchant{id: id, name: name, icon: icon}} ->
{id, name, icon}
{:error, _code, _message} ->
# invalid, too large or ambiguous input
:error
end

It composes naturally with pipelines and pattern matching:

with_icon =
for description <- ["ADOBE", "Google A0000033333", "PADARIA DO ZE 0042"],
{:ok, %MerchantIcons.Merchant{icon: icon} = merchant} when is_binary(icon) <-
[MerchantIcons.resolve(description)] do
merchant
end

If you only need the name, MerchantIcons.display_name/1 returns it directly:

MerchantIcons.display_name("DL * GOOGLE A0000000123")
#=> "Google"
MerchantIcons.display_name("PADARIA DO ZE 0042")
#=> nil

It returns nil for an unknown merchant and for any invalid input (it never raises). It does not return the description, not even a cleaned-up version of it, so what to show for an unknown merchant is up to your application:

MerchantIcons.display_name(description) || my_fallback_label(description)

Examples of what resolves to what:

Description Result
DL * GOOGLE A0000033333 Google
Google A0000033333 Google
Google One Google One
Uber UBER * PENDING Uber
DL * UberRides Uber
ADOBE Adobe
PADARIA DO ZE 0042 {:ok, :unknown}

Usage in a Phoenix application

The quickest path is the ready-made component. import MerchantIcons.Components and call merchant_icon/1 with the raw description. It resolves the merchant, renders the icon and handles merchants without one — you deal with none of that:

import MerchantIcons.Components
~H"""
<.merchant_icon name={@transaction.merchant_name} />
<.merchant_icon merchant={@transaction.merchant} />
<.merchant_icon name="Some Shop" size={40} class="shadow" />
<.merchant_icon name="Unknown LTDA" fallback={@store_svg} />
"""

It renders a self-contained round badge (inline styles, no CSS framework needed; size defaults to 32px, and class/other attributes pass through to the outer element). The icon is rendered as an <img> with a data: URI rather than inlined — see Rendering icons.

The initial badge takes its background from a built-in palette, always the same color for the same name. Pass color to use your own (color="#0F766E", color="teal", color="rgb(15 118 110)" or color="var(--brand)"). Only safe color shapes are accepted; anything else is ignored and the palette is used, so a value that came from a user can not add other CSS to the element. The initial is always white, so choose a color dark enough for it. A merchant that has an icon has no colored background, so color does not apply to it.

Fallback order, when the description has no bundled icon:

  1. the merchant's icon, when the description resolves to one;
  2. the fallback SVG markup you pass (validated the same way bundled icons are; invalid markup is ignored);
  3. a badge with the first letter of the merchant/description name.

Optional dependency

MerchantIcons.Components needs Phoenix.Component, so :phoenix_live_view is an optional dependency: projects that use the library only as a resolver never pull Phoenix in. In a Phoenix app you already have it, and the component is available. If the module does not appear, make sure :phoenix_live_view is compiled before :merchant_icons (the usual case in a Phoenix project).

Resolving once

name is resolved on every render. In a list, resolve when you load or store the data, keep the %MerchantIcons.Merchant{} (or at least its id) and pass it with merchant. No resolve happens then:

<.merchant_icon merchant={@transaction.merchant} />

When both name and merchant are given, merchant wins. size must be a positive integer; any other value is ignored and the default of 32 is used.

Rendering icons

Render icons as images, not as inline markup:

Inlining merchant.icon yourself is not the recommended path. The bundled files are checked when the library compiles, but that check works on the text of the file and is not a sanitizer for markup from other sources.

Input and output

Input

resolve/1 accepts any term and never raises because of its input. Descriptions are always treated as untrusted data. A valid description is a non-blank, valid UTF-8 binary of at most 1024 bytes. Larger input is rejected, not truncated. The limit is in bytes and is provisional: raising it later is compatible, lowering it is not.

Output

resolve/1 returns one of:

Result Meaning
{:ok, %MerchantIcons.Merchant{}} a merchant was identified
{:ok, :unknown} valid description, but no merchant in the dataset matches it
{:error, :invalid_input, msg} not a binary, invalid UTF-8, or empty/blank
{:error, :input_too_large, msg} larger than 1024 bytes
{:error, :ambiguous_merchant, msg} several merchants match and no rule picks one

An unknown merchant is not an error: it means the dataset does not cover that merchant yet.

Error tuples always have three elements. Branch on the code; the message is informative English text and is not part of the contract. No message contains the description or any other input. The set of codes is open (new codes may appear in minor versions), so keep a clause that matches any {:error, _code, _message}.

The merchant struct

Field Type Description
:id String.t() stable snake_case identifier, such as "google"
:name String.t() display name, such as "Google"
:icon String.t() | nil complete SVG markup of the logo, or nil when there is none

The struct only contains dataset values, never any part of the description. The icon is left out of inspect/1 because of its size. New fields may be added in minor versions, so match on the keys you need.

Icons

merchant.icon is the SVG itself, not a URL, path or slug:

{:ok, merchant} = MerchantIcons.resolve("ADOBE")
"<svg" <> _ = merchant.icon

Merchant logos are trademarks of their respective owners.

Telemetry

resolve/1 emits one :telemetry event when it identifies a merchant or concludes that it is unknown:

Event Measurements Metadata
[:merchant_icons, :resolve] %{count: 1} %{result: :merchant_resolved} or %{result: :merchant_unknown}
:telemetry.attach(
"merchant-icons-counter",
[:merchant_icons, :resolve],
&MyApp.Metrics.handle_event/4,
nil
)

The event never carries the description, the merchant or any other input. Validation errors and :ambiguous_merchant do not emit events in this version. Your application decides whether to attach a handler and what to do with the events; without a handler resolve/1 behaves the same. The library does not use Logger.

How matching works

Matching is deterministic and there is no substring matching.

Security and privacy

Limitations