crystal_iban

Version, currently master branch1 version
  • master branchlatestJun 19, 2026

github.com/tristanholl/crystal-iban

LCG-based IBAN generator. Uses a Linear Congruential Generator to produce pseudo-random account numbers and formats them as valid IBANs using the MOD-97 checksum algorithm. Supports 49 countries. IBAN structure data is embedded at compile time — no runtime file dependency.

1 stars
0 dependents
License: MIT

Installation

# Add this to your shard.yml
dependencies:
  crystal_iban:
    github: tristanholl/crystal-iban
    branch: master

master is a branch, not a release, so this tracks it rather than pinning a version.

Then run:

shards install

shard.yml

Crystal
>= 1.9.0
License
MIT
Author
tristanholl

Dependencies

This version declares no dependencies.

README

crystal-iban

crystal-iban (CI)

A Crystal shard for generating and validating IBAN (International Bank Account Number) strings. Uses the ISO 13616 MOD-97 checksum algorithm and supports 52 countries. IBAN structure data is embedded at compile time — no runtime file dependency.

Features

  • Generate structurally valid IBANs with a correct MOD-97 checksum
  • Validate any IBAN string with detailed error messages
  • Pseudo-random account number generation via a Linear Congruential Generator (LCG)
  • 52 countries supported out of the box
  • Extensible: supply your own JSON to add or override country definitions
  • Zero runtime file I/O — structure data is baked into the binary at compile time

Installation

Add the dependency to your shard.yml:

dependencies:
  crystal_iban:
    github: tristanholl/crystal-iban

Then run:

shards install

Quick Start

require "crystal_iban"

# --- Class methods (no instantiation needed) ---

# Generate a valid IBAN
CrystalIBAN::Generator.generate(country_code: "DE", bank_code: "37040044", account_number: 532_013_000_i64)
# => "DE89370400440532013000"

# Validate a string
CrystalIBAN::Validator.valid?("DE89 3704 0044 0532 0130 00")  # => true  (spaces and case are ignored)
CrystalIBAN::Validator.valid?("DE00 3704 0044 0532 0130 00")  # => false (bad checksum)

# Validate with error detail
CrystalIBAN::Validator.validate!("DE89 3704 0044 0532 0130 00")  # => "DE89370400440532013000"
CrystalIBAN::Validator.validate!("DE00 3704 0044 0532 0130 00")  # raises ArgumentError: "IBAN checksum invalid for DE00370400440532013000"

# --- Instance methods (explicit instantiation) ---

gen = CrystalIBAN::Generator.new
val = CrystalIBAN::Validator.new

iban = gen.generate(country_code: "DE", bank_code: "37040044", account_number: 532_013_000_i64)
# => "DE89370400440532013000"

val.valid?(iban)      # => true
val.validate!(iban)   # => "DE89370400440532013000"

API Reference

CrystalIBAN::Generator

Generates valid IBAN strings. Responsible solely for construction.

Constructors

# Standard: loads the bundled 52-country data compiled into the binary.
gen = CrystalIBAN::Generator.new

# Custom JSON: extend or override the default country set.
custom_json = %([{"country":"Testland","country_code":"TS","iban_format":"CCXX BBBB AAAA AAAA"}])
gen = CrystalIBAN::Generator.new(json: custom_json)

# Shared registry: avoids parsing JSON twice when also using a Validator.
registry = CrystalIBAN::StructureRegistry.new
gen = CrystalIBAN::Generator.new(registry: registry)

Class Method

CrystalIBAN::Generator.generate(country_code : String, bank_code : String, account_number : Int64) : String

Convenience class method — uses a shared default Generator instance (lazy-initialized, thread-unsafe). Equivalent to calling Generator.new.generate(...) but avoids creating a new instance on every call.

CrystalIBAN::Generator.generate(country_code: "LI", bank_code: "08810", account_number: 6_188_284_i64)
# => "LI05088106188284"

Instance Method

gen.generate(country_code : String, bank_code : String, account_number : Int64) : String

Generates a complete, valid IBAN string.

  • country_code — ISO 3166-1 alpha-2 code (e.g. "DE", "LI", "CH")
  • bank_code — Bank identifier string; must match the length defined for the country
  • account_number — Account number as Int64; automatically zero-padded to the country's required length

Raises ArgumentError if country_code is not in the loaded structure data.

gen.generate(country_code: "LI", bank_code: "08810", account_number: 6_188_284_i64)
# => "LI05088106188284"

gen.generate(country_code: "CH", bank_code: "00762", account_number: 11_863_i64)
# => "CH5600762000000011863"

CrystalIBAN::Validator

Validates IBAN strings against the ISO 13616 standard. Responsible solely for validation.

Constructors

# Standard: loads the bundled 52-country data.
val = CrystalIBAN::Validator.new

# Shared registry: avoids parsing JSON twice when also using a Generator.
registry = CrystalIBAN::StructureRegistry.new
val = CrystalIBAN::Validator.new(registry: registry)

Class Methods

CrystalIBAN::Validator.valid?(iban : String) : Bool

Convenience class method — uses a shared default Validator instance. Equivalent to Validator.new.valid?(iban).

CrystalIBAN::Validator.valid?("LI05 0881 0061 8828 4")   # => true
CrystalIBAN::Validator.valid?("LI00 0000 0000 0000 0")   # => false
CrystalIBAN::Validator.validate!(iban : String) : String

Convenience class method — uses a shared default Validator instance. Equivalent to Validator.new.validate!(iban).

CrystalIBAN::Validator.validate!("LI05 0881 0061 8828 4")
# => "LI050881006188284"

CrystalIBAN::Validator.validate!("LI00 0000 0000 0000 0")
# raises ArgumentError: "IBAN checksum invalid for LI000008810000006188284"

Instance Methods

valid?(iban : String) : Bool

Returns true if the IBAN is valid, false otherwise. Never raises.

Before checking, the input is stripped of all spaces and uppercased — human-formatted IBANs like "DE89 3704 0044 0532 0130 00" are accepted.

Checks performed (in order):

  1. Length is at least 4 characters
  2. Country code (first 2 characters) is supported
  3. Total length matches the country's expected IBAN length
  4. MOD-97 checksum equals 1 (ISO 13616)
val.valid?("LI05 0881 0061 8828 4")   # => true
val.valid?("li05 0881 0061 8828 4")   # => true  (case insensitive)
val.valid?("LI00 0000 0000 0000 0")   # => false (bad checksum)
val.valid?("XX050881006188284")        # => false (unknown country)
val.valid?("LI")                      # => false (too short)
validate!(iban : String) : String

Returns the normalized IBAN (uppercased, spaces removed) if valid. Raises ArgumentError with a descriptive message on the first failing check.

val.validate!("LI05 0881 0061 8828 4")
# => "LI050881006188284"

val.validate!("LI0008810000006188284")
# raises ArgumentError: "IBAN checksum invalid for LI0008810000006188284"

val.validate!("LI0608810000006188")
# raises ArgumentError: "IBAN length 18 invalid for LI (expected 21)"

val.validate!("XX050881006188284")
# raises ArgumentError: "Country XX not supported"

CrystalIBAN::StructureRegistry

Parses and holds the country IBAN pattern data. Normally used indirectly through Generator and Validator, but can be instantiated directly to share parsed data between both:

registry = CrystalIBAN::StructureRegistry.new

gen = CrystalIBAN::Generator.new(registry: registry)
val = CrystalIBAN::Validator.new(registry: registry)

# Both now share one parsed Hash — the JSON is only parsed once.
iban = gen.generate(country_code: "DE", bank_code: "37040044", account_number: 532_013_000_i64)
val.valid?(iban)  # => true

The registry exposes a read-only structures getter:

registry.structures         # => Hash(String, IbanPattern)
registry.structures["DE"]   # => IbanPattern for Germany
registry.structures["DE"].bank_code_length          # => 8
registry.structures["DE"].account_number_length     # => 10

CrystalIBAN::LCG

A Linear Congruential Generator for producing pseudo-random account numbers. Useful for test data generation or seeded, reproducible sequences.

lcg = CrystalIBAN::LCG.new         # default seed: 1
lcg = CrystalIBAN::LCG.new(42_i64) # custom seed

Methods

lcg.next_number : Int64   # stateful: advances internal state and returns the next value
LCG.step(n : Int64) : Int64  # pure function: returns the next value from n without creating an instance
lcg = CrystalIBAN::LCG.new(1_i64)
lcg.next_number  # => 6_188_284
lcg.next_number  # => advances again; deterministic from the same seed

CrystalIBAN::LCG.step(1_i64)  # => 6_188_284 (same result, no state)

The generator produces up to 16,777,213 unique values before the sequence repeats (period = MODULO = 2²⁴ − 3). Constants are sourced from AMS research paper S0025-5718-99-00996-5.

Combining LCG with Generator:

lcg = CrystalIBAN::LCG.new
gen = CrystalIBAN::Generator.new

10.times do
  account = lcg.next_number
  puts gen.generate(country_code: "LI", bank_code: "08810", account_number: account)
end

Deprecated: CrystalIBAN::IBANGenerator

IBANGenerator is a backward-compatibility type alias for Generator:

alias IBANGenerator = Generator

All existing call sites continue to compile unchanged. IBANGenerator also retains valid? and validate! as deprecated delegation methods that forward to an internally managed Validator.

Prefer using Generator and Validator directly in new code.


IBAN Structure and Format

An IBAN is composed of four parts concatenated without spaces:

{CC}{XX}{BBBBB...}{AAAAAA...}
 ^    ^    ^          ^
 |    |    |          Account number (zero-padded to country length)
 |    |    Bank code (length varies by country)
 |    2-digit MOD-97 checksum
 2-letter ISO country code

The iban_format field in the bundled JSON encodes this layout using placeholder characters:

CharacterMeaning
CCountry code position (always 2)
XChecksum position (always 2)
BBank code digit (count = bank code length)
AAccount number digit (count = account number length)

Example for Germany (DE):

Format:  CCXX BBBB BBBB AAAA AAAA AA
Decoded: 2-char country + 2-char checksum + 8-char bank code + 10-char account
Total:   22 characters

Spaces in the format string are cosmetic — the generated IBAN contains no spaces.


MOD-97 Checksum Algorithm (ISO 13616)

The checksum is calculated as follows:

Generation:

  1. Construct the string: {bank_code}{account_number}{country_code}00
  2. Replace each letter with its numeric code: A=10, B=11, ..., Z=35
  3. Compute 98 - (numeric_value mod 97), zero-padded to 2 digits

Validation:

  1. Normalize the IBAN (uppercase, remove spaces)
  2. Rearrange: move the first 4 characters to the end
  3. Replace each letter with its numeric code
  4. Compute numeric_value mod 97 — must equal 1

Crystal requires explicit BigInt for this calculation because the numeric string can exceed 30 digits, which is well beyond the Int64 range.


Supported Countries

52 countries are bundled in data/iban_structure.json and compiled into the binary:

CodeCountryIBAN Length
ALAlbania28
ATAustria20
BABosnia and Herzegovina20
BEBelgium16
BGBulgaria22
CHSwitzerland21
CYCyprus28
CZCzech Republic24
DEGermany22
DKDenmark18
EEEstonia20
ESSpain24
FIFinland18
FOFaroe Islands18
FRFrance27
GBUnited Kingdom22
GEGeorgia22
GIGibraltar23
GLGreenland18
GRGreece27
HRCroatia21
HUHungary28
IEIreland22
ILIsrael23
ISIceland26
ITItaly27
KWKuwait30
KZKazakhstan20
LBLebanon28
LILiechtenstein21
LTLithuania20
LULuxembourg20
LVLatvia21
MCMonaco27
MEMontenegro22
MKMacedonia19
MRMauritania27
MTMalta31
MUMauritius30
NLNetherlands18
NONorway15
PLPoland28
PTPortugal25
RORomania24
RSSerbia22
SASaudi Arabia24
SESweden24
SISlovenia19
SKSlovak Republic24
SMSan Marino27
TNTunisia24
TRTurkey26

Custom Country Definitions

To add countries not in the bundled list, supply a JSON string in the same format as data/iban_structure.json:

custom_json = %([
  {
    "country": "Testland",
    "country_code": "TS",
    "iban_format": "CCXX BBBB AAAA AAAA"
  }
])

gen = CrystalIBAN::Generator.new(json: custom_json)
gen.generate(country_code: "TS", bank_code: "1234", account_number: 56_i64)
# => "TS741234000000056"  (example — checksum will vary)

The format string rules:

  • Must contain exactly 2 C characters and exactly 2 X characters
  • B characters define the bank code length
  • A characters define the account number length
  • Spaces are cosmetic and ignored during parsing
  • country_code must be exactly 2 characters

An ArgumentError is raised at construction time if any entry fails these constraints.


Project Structure

crystal-iban/
├── src/
│   ├── crystal_iban.cr          # Main entry point — requires all modules
│   ├── generator.cr             # CrystalIBAN::Generator
│   ├── validator.cr             # CrystalIBAN::Validator
│   ├── structure_registry.cr    # CrystalIBAN::StructureRegistry
│   ├── checksum_util.cr         # CrystalIBAN::ChecksumUtil (MOD-97 helpers)
│   ├── iban_generator.cr        # Backward-compat alias: IBANGenerator = Generator
│   ├── lcg.cr                   # CrystalIBAN::LCG
│   └── models/
│       └── iban_structure.cr    # IbanEntry (JSON mapping), IbanPattern (domain model)
├── spec/
│   ├── spec_helper.cr
│   ├── generator_spec.cr
│   ├── validator_spec.cr
│   ├── structure_registry_spec.cr
│   ├── iban_generator_spec.cr   # Legacy spec — exercises IBANGenerator alias
│   └── lcg_spec.cr
└── data/
    └── iban_structure.json      # 52-country IBAN format definitions

Running Tests

Tests require Docker:

make test

To open a development console:

make console

Test Coverage

Spec fileWhat it covers
generator_spec.crIBAN length per country, country code placement, checksum position, bank code embedding, account number zero-padding, unsupported country error, custom JSON constructor
validator_spec.crRound-trip validation of generated IBANs, space/case normalization, errors for unknown country / wrong length / bad checksum, shared StructureRegistry cross-object validation
structure_registry_spec.crBundled data loads correctly, specific country codes present, custom JSON parsing, malformed entry raises
iban_generator_spec.crFull legacy API surface via the IBANGenerator alias — ensures backward compatibility
lcg_spec.crCorrect first value from seed 1, state advancement, determinism across instances, range bounds, pure .step consistency

License

MIT — see LICENSE.