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 A0000021232")
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 A0000021232
Google ADS2397919998
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.1.0"}
]
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 ADS2397919998", "PADARIA DO ZE 0042"],
{:ok, %MerchantIcons.Merchant{icon: icon} = merchant} when is_binary(icon) <-
[MerchantIcons.resolve(description)] do
merchant
end

Examples of what resolves to what:

Description Result
DL * GOOGLE A0000021232 Google
Google ADS2397919998 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

merchant.icon is trusted markup that ships with the library, so it can be rendered unescaped with Phoenix.HTML.raw/1. Never do this with text that came from the description: the description is never part of the struct.

defmodule MyAppWeb.MerchantComponents do
use Phoenix.Component
attr :description, :string, required: true
def merchant(assigns) do
merchant =
case MerchantIcons.resolve(assigns.description) do
{:ok, %MerchantIcons.Merchant{} = merchant} -> merchant
{:ok, :unknown} -> nil
{:error, _code, _message} -> nil
end
assigns = assign(assigns, :merchant, merchant)
~H"""
<span :if={@merchant} class="merchant">
<span :if={@merchant.icon} class="merchant-icon">{Phoenix.HTML.raw(@merchant.icon)}</span>
{@merchant.name}
</span>
<%!-- Unknown merchant or error: show the original text, which HEEx escapes. --%>
<span :if={!@merchant} class="merchant">{@description}</span>
"""
end
end

This uses the {...} syntax of Phoenix LiveView 1.0 and later. Size the icon with CSS, for example .merchant-icon svg { width: 1.5rem; height: 1.5rem; }.

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