Payee and bank packs with logos for Kazna.
Served by GitHub Pages at https://catalog.kazna.app, straight from this repository: a push to main is live within minutes, and what isn’t committed isn’t published.
You edit packs/ and meta/. The app reads them in two forms:
build.py generates them from packs/ and meta/, and they are committed like everything else.The layout is flat: one folder per pack, edited in place, with no version folders. Pack and logo URLs therefore stay the same for good, and a pack’s version tells installed apps that it changed.
catalog.json v1 list of every pack (edited by hand)
packs/{id}/
pack.json the pack itself: what the app installs
logos/{key}.png one logo per payee or bank, named by its key
sources.json where each logo came from (for us; the app never reads it)
meta/ what the v2 index adds to the packs (edited by hand)
categories.json brand categories
packs.json every pack's rank, countries and default category
entities.json overrides by key
regions.json reserve currencies and short country names
renamed.json old key -> the key that replaced it
index/v2/ GENERATED by build.py and committed; never edit it by hand
banks.json
brands.json
logos/{h}.png a copy of every logo the index uses, named by its hash; only ever added to
schema/
catalog.schema.json the shape of catalog.json
pack.schema.json the shape of packs/{id}/pack.json
validate.py checks packs/ and catalog.json
build.py writes index/v2/ from packs/ and meta/; --check verifies it
.github/workflows/check.yml runs both checks on every push and pull request
packs/ or meta/, and catalog.json if a pack’s listing changed.python3 build.py. It must run after every change to packs/ or meta/, even a logo-only fix: it rewrites index/v2/banks.json and brands.json and adds the new logo copies.python3 validate.py and python3 build.py --check. Before a push, add --base origin/main to both, so they compare with what is published.packs/, meta/, catalog.json and index/v2/ together, and push to main.Build on the day you commit. The index’s updated is the date of the last commit that touched a pack.json, a pack’s logos or meta/, so an index built the day before reads as stale.
CI (.github/workflows/check.yml) runs validate.py and build.py --check on every push and pull request, both compared with the commit before the push or with the pull request’s base. It fails when index/v2/ wasn’t rebuilt and committed.
Both scripts use only the Python standard library, so they need no venv.
Top level: schema_version (1), generated_at (the date of the last edit), base_url (https://catalog.kazna.app, which the app also has built in) and packs.
Each entry in packs:
| Field | Description |
|---|---|
id |
The pack’s folder name, kebab-case |
type |
payees or banks |
version |
The same number as in pack.json |
name, description |
Localized text (see below) |
tags |
Kebab-case labels. The app stores them but doesn’t show them yet. |
entity_count |
How many payees or banks pack.json has |
pack_url |
{base_url}/packs/{id}/pack.json |
preview_logos |
Up to 4 logo URLs of the pack’s own payees or banks, shown on payee pack cards before install |
Localized text is an object keyed by language code, such as {"en": "Russian Supermarkets", "ru": "Супермаркеты"}. en is required, and the app falls back to it. Any code is allowed. Builds up to 1.12.15 pick the device’s language, and later ones pick one of en, ru, de, es and fr.
{
"id": "ru-supermarkets", "version": 3, "type": "payees",
"name": { "en": "Russian Supermarkets", "ru": "Супермаркеты" },
"payees": [
{ "key": "pyaterochka", "name": { "en": "Pyaterochka", "ru": "Пятёрочка" },
"logo": "https://catalog.kazna.app/packs/ru-supermarkets/logos/pyaterochka.png",
"mcc": [5411], "website": "https://5ka.ru", "aliases": ["5ka"] }
]
}
A payee pack has payees and a bank pack has banks, never both. A pack’s rank, countries and default category are not in pack.json but in meta/packs.json.
Payee:
| Field | Description | Read by |
|---|---|---|
key, name, logo |
Required | every build |
mcc |
Merchant category codes, 1–9999 | the v2 index only (builds parse it from pack.json but never use it) |
website |
The brand’s site | the v2 index only (likewise) |
aliases |
Other names people search by: short forms, other scripts, misspellings. A brand’s earlier names go in former in meta/entities.json instead. |
the v2 index only |
countries |
ISO codes, or null for international; replaces the countries of the packs that list the key |
the v2 index only |
category |
A category id from meta/categories.json, in place of the pack’s default |
the v2 index only |
match |
"pick" when the name is too generic (МЕТРО, Claude): the app suggests the brand but never links a payee of the user’s to it by name alone |
install and update, from 1.12.17 |
Bank:
{ "key": "tbc-ge", "name": { "en": "TBC Bank", "ru": "ТБС Банк" },
"logo": "https://catalog.kazna.app/packs/ge-banks/logos/tbc-ge.png",
"countries": ["GE"], "website": "https://tbcbank.ge",
"card_background": "solid:#00ADEE", "accent_color": "#00ADEE", "brand": "tbc" }
| Field | Description |
|---|---|
key, name |
Required |
logo |
Optional. A bank without one (a closed bank, say) is shown by its initial. |
countries |
ISO 3166-1 alpha-2 codes, or null for an international bank such as Revolut |
website |
The bank’s site. No build reads it yet. |
card_background |
solid:#RRGGBB, the colour of the bank’s cards |
accent_color |
#RRGGBB |
closed |
true for a bank that no longer operates |
brand |
The group this entry is one country of. The app shows each group as one bank. |
aliases |
Other names people search by (Тинькофф for T-Bank). Read by the v2 index only. |
meta/entities.json overrides aliases, countries, category and name (see meta/), in the v2 index only.
"old-key": "new-key" to meta/renamed.json; the checks fail when a published key disappears without one._ (bank_of_america, wells_fargo, deutsche_bank, bnp_paribas, credit_agricole, societe_generale). The app’s seed pins them, so they stay as they are. The schema allows exactly these six.glovo, yandex-go and lamoda. Its copies must be identical in every field and in the logo’s bytes, and the v2 index makes them one brand. Builds 1.12.16 and older can still drop a shared payee that has no transactions when one of its packs is removed.sberbank), the main entry has no brand, and the others say "brand": "sberbank" (sberbank-by). A group without a main entry gives every entry the same name that is no bank’s key (tbc for tbc-ge and tbc-uz). Banks that are one institution across countries (Revolut, Wise, N26) stay a single entry.version, in pack.json and in its catalog.json entry.name, entries added or removed, and these entry fields:
key, name, logo;key, name, logo, countries, card_background, accent_color, closed, brand;aliases, a payee’s category, countries, match, mcc and website, a bank’s website, the order of the entries, sources.json, meta/, and a logo file no entry uses. Every bump shows an update badge to everyone who installed the pack, and on 1.12.16 and older an update can retitle its payees. validate.py warns about a bump that changed nothing those builds read.description, tags and preview_logos live in catalog.json only, so they need no bump either.Record every logo in its pack’s sources.json, keyed by entity key:
{ "lukoil": { "source": "https://commons.wikimedia.org/wiki/File:LUK_OIL_Logo.svg",
"kind": "Wikimedia Commons SVG", "licence": "Public domain; trademark", "fetched": "2026-10-08",
"processing": "rendered/cropped to the mark, fitted to 128x128 RGBA with a 6 px margin" } }
Use "source": null when the URL is unknown, and say why in note.
What the v2 index adds to the packs. It overrides anything a pack.json says, and changing it never needs a version bump.
| File | Holds |
|---|---|
meta/categories.json |
The brand categories: id, rank, icon (a short id the app maps to its own glyph), color (#RRGGBB) and n, names in en, ru, de, es and fr. |
meta/packs.json |
Every pack: rank, countries ([] means international) and, for payee packs, the category its entries get unless they say otherwise. The build fails on a pack that is missing. |
meta/entities.json |
Overrides by key: category, countries, aliases, pick: true (suggest, never link by name), full_bleed: true (a tile the app can’t detect), and the names below: name, former, translit: true. An override for a key no pack has fails the build. A key that is both a bank and a brand (paypal) gets the override in both files, except former and translit, which are for brands only. |
meta/regions.json |
reserve currencies, currency → country regions overrides, and short country_names. |
meta/renamed.json |
Old key → the key that replaced it, a key of the same kind. Entries are never removed. |
A name in the index is for good. Search finds a brand by every name it ever had, and the app tells a pack’s untouched payee from one the user renamed by comparing its title with the brand’s names, so build.py --check fails when a name published in the committed index (any language’s n, and a brand’s o and r) is gone from its entry, or from the entry of the key that replaced it: a brand must keep it in n, a, o or r, a bank in n or a. Aliases alone may go. It warns, though, when a bank alias published there is gone, since an alias is all that keeps a bank’s old name.
Rename in meta/entities.json, not in pack.json. name is merged over the pack’s names by language, in the index only: pack.json keeps its names, so builds up to 1.12.17 see no change and no version bump is needed. Every pack name it replaces lands in the brand’s r by itself, so «Эппл» needs no alias to stay searchable.
"sas": { "name": { "ru": "Скандинавские Авиалинии" } },
"zolotaya-korona": { "name": { "en": "Zolotaya Korona" } },
"apple": { "name": { "ru": "Apple" }, "translit": true },
"kuper": { "former": ["СберМаркет", "SberMarket"] }
name: names by language, merged over the pack’s. It can add a language but not remove one. An override that changes nothing warns, and so does one that changes an en name: en is what every language without its own name shows. A bank’s name replaces its names too, but banks.json has no r, so add the name it replaces to the bank’s aliases, or the check fails.former (brands only): the names the brand itself went by before a rebrand. They are published as o, searched and shown with the brand («Также»). A former name can’t be one of its current names. A bank’s earlier names go in its aliases.translit: true (brands only, with name): the override Latinises a name written in another script (Эппл → Apple). The brand’s names must then be one Latin name, the same in every language the brand has, uk or kk included, or the build fails. The replaced names that aren’t Latin are also published as t.Edit a name in pack.json only for something builds up to 1.12.17 must see as well. That needs a version bump (see Versions), and the old name has to stay in aliases or former.
index/v2/banks.json and index/v2/brands.json hold every bank and every brand, with short field names to keep them small. The app parses them leniently, ignoring fields it doesn’t know. Change the meaning of a field only together with the app.
Both files start with:
| Field | Meaning |
|---|---|
format |
2. The app ignores a file with any other format. |
rev |
The content’s revision: the first 16 hex digits of HMAC-SHA256 with the key kazna-index-v1 over the file’s canonical JSON (sorted keys, no spaces, UTF-8) without rev and updated. Use this, not the ETag, to tell whether the file changed: Pages changes every ETag on every deploy. |
updated |
The date of the last commit that touched a pack.json, a pack’s logos or meta/ |
min_app_build |
The oldest app version that may read the file, as major × 1,000,000 + minor × 1,000 + patch of the version both platforms share (1.13.0 = 1013000), or 0 for any. Not a store build number: every build that reads index v2 counts as 1012018 or more, so 66 would turn no one away, and build.py fails on anything between 1 and 999,999. Older builds ignore the file and keep what they have. It is MIN_APP_BUILD in build.py. |
base_url, logo_path |
A logo’s URL is base_url + logo_path, with {h} replaced by the entry’s h |
renamed |
Old key → new key, from meta/renamed.json, in the file of the new key’s kind |
banks.json also has reserve, regions and country_names from meta/regions.json. brands.json also has categories from meta/categories.json.
Banks, ordered by pack rank, then by their order in the pack, which is the curated popularity order:
| Field | Meaning | From |
|---|---|---|
k |
Key | key |
p |
Pack | the folder |
b |
Brand group, only on entries whose key isn’t the group’s; the app groups by b, else k |
brand |
n |
Names by language | name, with name in meta/entities.json merged over it |
a |
Aliases | meta/entities.json, else aliases |
c |
Countries; absent means international | meta/entities.json, else countries, else meta/packs.json |
h |
Logo hash; absent means no logo | the logo file |
f |
1: the logo is a full-bleed tile the app can’t detect |
full_bleed in meta/entities.json |
bg |
Card colour | a solid: card_background |
ac |
Accent colour | accent_color |
x |
1: closed |
closed |
Brands, one per payee key, ordered by category rank, then pack rank, then their order in the pack:
| Field | Meaning | From |
|---|---|---|
k, n, a, h, f |
As for banks | the same |
o |
Former names: what the brand went by before a rebrand. Searched, shown with the brand | former in meta/entities.json |
r |
Replaced names: every pack name a name override replaced and the brand no longer shows. Searched, never shown |
worked out by build.py |
t |
Transliterations: the names in r that a translit: true override replaced and that aren’t Latin. No app reads it yet |
worked out by build.py |
p |
The first pack, in rank order, that lists the key | |
ps |
Every pack that lists the key, when there is more than one | |
g |
Category id; always present | meta/entities.json, else category, else the first pack’s category in meta/packs.json |
c |
Countries; absent means international | meta/entities.json, else countries, else the union over the packs that list the key (an international pack makes it international, and build.py warns when that takes the countries away from a brand that had them) |
m |
MCC codes | mcc |
w |
Website without its scheme or final slash; a path stays (www.tbank.ru/insurance) |
website |
pick |
1: suggest, never link by name |
pick in meta/entities.json, or "match": "pick" |
Logos. h is the first 16 hex digits of HMAC-SHA256 with the key kazna-logo-v1 over the PNG’s bytes. The app has the same function, and it drops a logo whose bytes don’t match. build.py copies each logo to index/v2/logos/{h}.png. A fixed logo gets a new hash, so a new URL: the CDN ignores query strings and caches for 10 minutes, so a URL must change whenever its bytes do. These copies are never edited or deleted, only added, because an older index or a snapshot bundled in an app may still point at them. build.py fails when a copy committed at --base is gone, even one nothing references any more.
Size budgets: banks.json at most 120 KB raw or 30 KB gzipped, and brands.json at most 320 KB raw or 70 KB gzipped. build.py warns at 80%.
python3 validate.py # before a commit: compares the packs with HEAD
python3 build.py --check # before a commit: index/v2/ is current and meta/ complete
python3 validate.py --base origin/main # before a push: compares with what is published
python3 build.py --check --base origin/main
Both exit with status 1 on errors and list warnings separately.
validate.py checks the packs and catalog.json. Its errors:
id, type, version or name disagree between catalog.json and pack.json, or entity_count is wrong.pack_url is not {base_url}/packs/{id}/pack.json, or a pack is listed twice.meta/renamed.json doesn’t name the key that replaced it, or names one no pack of that kind has.Its warnings:
packs/{id}/logos/{key}.png, or a file in logos/ is used by no entity.brand is shared by no other bank and is no bank’s key.sources.json, or it records no source for some logos.python3 build.py --check to see them.Without git, or with a --base that isn’t a commit, the version and key checks are skipped with a warning. The schemas are checked by a small built-in JSON Schema subset. A schema keyword it doesn’t implement stops the run (exit status 2), so a schema edit can’t silently go unchecked.
build.py –check writes nothing. It fails when:
index/v2/ is stale, or a logo copy’s bytes don’t match its name;--base is gone;MIN_APP_BUILD is neither 0 nor on the app’s version scale (see Index v2);meta/packs.json, a category is unknown, an override names a key no pack has, or a meta/ file has the wrong shape;--base (HEAD by default) is gone without a meta/renamed.json entry, or an old renamed entry is gone;--base (an n, or a brand’s o or r) is gone from its entry, or from the entry of the key that replaced it: a brand keeps it in n, a, o or r, a bank in n or a (see Names);translit: true brand’s names aren’t one Latin name, the same in every language; a former name is one of the brand’s names; former or translit is set for a bank;It warns when:
name override changes an en name, or changes nothing;--base is international now, which the union over its packs does when an international pack lists it;--base is gone;packs/{id}/pack.json and add the logos to packs/{id}/logos/.packs/{id}/sources.json.preview_logos, and set generated_at to today.meta/packs.json with a rank, its countries and, for payees, a category. Give entries that belong elsewhere a category or countries of their own, in pack.json or in meta/entities.json.python3 build.py, then the checks. Commit the pack, catalog.json, meta/ and index/v2/ together, and push to main.A new country’s bank pack installs itself for people in that country only after an app release maps the country’s currency and seeds the pack. Until then, people add it from Banks → Bank Catalog.
version in both pack.json and catalog.json. Update entity_count, description and preview_logos if they changed, and set generated_at to today."old-key": "new-key" to meta/renamed.json.python3 build.py, then the checks. Commit the pack, catalog.json, meta/ and index/v2/ together, and push to main.