MerchantIcons
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:
- validates and normalizes it (Unicode NFKD, accents removed, case folded, zero-width characters removed);
- splits it into tokens and ignores a known processor prefix;
- matches it against a small static dataset of merchants;
- returns
{:ok, %MerchantIcons.Merchant{}}with the merchantid,nameand, 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:
- It has no network access. Resolution and icons work offline, and the SVGs are embedded when the library compiles.
- It keeps no state: no database, cache, process or file access at runtime.
- It does not deal with amounts, currencies, categories or any other financial data. The description is only the input used to find the merchant.
- It never logs, stores or returns the description (see Security).
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:
- the merchant's icon, when the description resolves to one;
- the
fallbackSVG markup you pass (validated the same way bundled icons are; invalid markup is ignored); - 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:
MerchantIcons.icon_data_uri/1returns adata:image/svg+xml;base64,...string for thesrcof an<img>. The component uses it.- An
<img>cannot run scripts or load other resources, and it keeps the internal ids of each SVG (gradients,clipPath, filters referenced withurl(#id)) inside the image. Inlining the same icon more than once on a page, such as in a list or in LiveView's server and client DOM, makes those ids collide and the references stop painting. - Content-Security-Policy: if your application sets
img-src, it must allowdata:(for exampleimg-src 'self' data:), or the icons will not show. - Each
data:URI is part of the HTML you send. The icons are small (the largest bundled one is about 11 KB encoded), but a page with thousands of rows repeats it in every row. Paginate or use LiveView streams for long lists.
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
- Icons live in
priv/icons/*.svgand are embedded in the library at compile time. Getting an icon never touches the network or the file system. - Every icon has a
viewBox, so it scales with CSS. Render it as an<img>(see Rendering icons). - Every SVG is validated while the library compiles, and a bad file stops the build. The checks
are deliberately strict and work on the text of the file (there is no XML parser):
- only these elements are allowed:
svg,g,path,rect,circle,ellipse,line,polyline,polygon,defs,symbol,use,title,desc,stop,linearGradient,radialGradient,pattern,clipPath,mask,filterand thefe*filter primitivesfeGaussianBlur,feOffset,feBlend,feColorMatrix,feComposite,feFlood,feMergeandfeMergeNode. Any other element is rejected, including HTML elements,<style>,<a>and animation elements; so are comments, CDATA and processing instructions. A rejected element is named in the build error; - rejected: scripts, event handlers (
on*),foreignObject,<iframe>,<object>,<embed>,<!DOCTYPE>, entities and any&,javascript:anddata:URIs, and@import; href,srcandurl(...)may only point to an#idinside the same file, so any other reference, such ashttp:,//hostor a relative path, is rejected;- the file must start with
<svg(with aviewBox), have a single root that is closed by the last tag of the file (nothing between or after roots), be a regular file (no symlinks) and be at most 100,000 bytes.
- only these elements are allowed:
- When
iconisnil, the merchant has no icon yet. Treat it like an unknown icon and choose your own fallback.
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
- Normalization: Unicode NFKD, accents removed, case folded, zero-width characters removed.
- Tokens: the text is split at separators and at letter/digit boundaries. CamelCase is not
split, so
UberRidesis a single token. - Processor prefixes: leading
dl,dm,ebn,ppro,dlocalorebanxtokens are ignored (also when repeated), soDL * GOOGLE A0000000123is matched asGOOGLE A 0000000123. Any other leading token (for examplePAYPAL) is not skipped, and a prefix in the middle of a description is not ignored. - Aliases are token sequences of two kinds:
:leading: the alias must start the description, and anything may follow it.:whole: the alias must account for the whole description; only digits-only tokens may follow it. Used for short or generic names such asMiroorSentry.
- Conflicts: the alias with more tokens wins, then
:wholewins over:leading. If different merchants remain, the result is:ambiguous_merchant.
Matching is deterministic and there is no substring matching.
Security and privacy
- Descriptions are untrusted: input is size-limited and validated before any processing, and
nothing is built dynamically from it (no regex compilation from input, no
eval). - The description is never logged, stored, put in error messages, in the returned struct or in telemetry events.
- No network access and no persistence.
Limitations
- Small dataset: it currently contains 110 merchants, and 30 of them have an icon (the
SVGs in
priv/icons). The others returnicon: nil. - Unknown merchants: anything outside the dataset returns
{:ok, :unknown}. Merchants are added to the library itself; there is no API for custom merchants or aliases yet. - Conservative matching: because there is no substring matching, a name glued to a code (for
example
GOOGLEADS 123) or in the middle of a description (MY GOOGLE) is not matched. This avoids false positives at the cost of some false negatives. - Few processor prefixes: only
dl,dm,ebn,ppro,dlocalandebanxare skipped. A processor that is not on the list (PAYPAL,PG,EC,MP...) hides the merchant unless the dataset has an alias that spells the processor out, such aspg zapsign. - Payment processors that are also merchants: a description such as
PADDLE.NET * <vendor>belongs to the vendor, not to the processor, so it is unknown unless the vendor is in the dataset. Only the processor's own charge (PADDLE.NET * PADDLE.NET) resolves to Paddle. - Homoglyphs and non-Latin text: Cyrillic or other look-alike letters are not mapped to Latin, and non-Latin descriptions are preserved as content and will usually be unknown.
- Input limit: 1024 bytes, provisional.
- Telemetry: only resolved and unknown results are reported.