
Parses transaction SMS into structured records (amount in minor units, type, merchant, reference, balance, fee) using JSON templates, ISO-wide currency detection, fallbacks and privacy-first offline parsing.
Kotlin library that parses transaction SMS from mobile money and bank messages into structured data.
Hand it the sender ID, message body, and timestamp of one SMS; it returns a
ParsedTransaction (amount in minor units, type, merchant, reference,
balance, fee) or an explicit failure with a reason. It never returns null,
never implies a currency, and makes no network calls.
Under active development. Apache 2.0 - see LICENSE.
The parser is built for any market: currency detection is ISO-wide (every active ISO 4217 code, as of v0.2.0), and provider templates are plain JSON data on one engine. Coverage today comes in two honest tiers:
If your market is in the second tier, that is an invitation: adding a provider is template data plus sample fixtures, not parser code. See CONTRIBUTING.md for the workflow. The current template list is generated from the registry into docs/SUPPORTED_PROVIDERS.md.
The library targets the JVM in v1; Android apps consume the JVM jar.
Gradle (Kotlin DSL):
dependencies {
implementation("io.github.peteretelej:sms-transaction-parser:0.2.0")
}Maven:
<dependency>
<groupId>io.github.peteretelej</groupId>
<artifactId>sms-transaction-parser</artifactId>
<version>0.2.0</version>
</dependency>Parse one message:
import io.github.peteretelej.smstransactionparser.BuiltinTemplates
import io.github.peteretelej.smstransactionparser.ParseResult
import kotlin.time.Instant
val registry = BuiltinTemplates.defaultRegistry()
val result = registry.parse(
senderId = "MPESA",
body = "Confirmed. Ksh1,500.00 sent to JOHN DOE EXAMPLE 0749000001 on 05/01/26 at 10:20 AM. " +
"New M-PESA balance is Ksh8,500.00. Transaction cost, Ksh57.00.",
timestamp = Instant.parse("2026-01-05T10:20:30Z"),
)
when (result) {
is ParseResult.Success ->
println("${result.transaction.type} ${result.transaction.amountMinorUnits}")
is ParseResult.Failure ->
println("${result.reason}: ${result.detail}")
}registry.parse returns one of:
ParseResult.Success, carrying the ParsedTransaction and the templateId
that produced it (null for fallback rows, which no template produced).ParseResult.Failure, carrying a ParseFailureReason:| Reason | Meaning | What a consumer usually does |
|---|---|---|
NO_ROUTE |
Nothing parsed and the fallback leg did not run: the message is not transaction-shaped, or the registry was built with the fallback disabled. | Ignore; nothing was lost. |
NO_MATCH |
The message looks transaction-shaped, but no amount could be extracted. | Ignore, or treat like NO_ROUTE. |
INCOMPLETE |
A routed template's shape matched, but the amount is missing. | Review-worthy: the message belongs to a known provider shape. |
Every success also carries a ParseConfidence:
HIGH: count it as parsed.LOW: parsed, but reviewReason says why it needs a human (for example
"direction" or "amount"). Do not auto-count these. This is today the
only non-HIGH value registry.parse returns; MEDIUM and FAILED are
reserved enum values that parse does not produce.A transaction whose text names no currency keeps currency = null and
currencyDeclared = false. If you pass defaultCurrency, fallback-matched
rows with no named currency carry it - still flagged undeclared - while
template-parsed rows keep null; your totals stay honest about what the
message actually said.
All amounts are Long minor units scaled by the ISO 4217 exponent of the
currency: "Ksh1,500.00" parses to 150000 (KES exponent 2), a
zero-decimal currency like UGX parses to units, and exponentFor covers the
full ISO table (3-decimal currencies such as KWD return 3). No Double is
used anywhere in the pipeline. Render with the exponent, not by hard-coding
two decimals.
registry.parse(
senderId = sender,
body = body,
timestamp = timestamp,
defaultCurrency = "KES", // fallback rows with no named currency carry this
preferMonthFirst = true, // read 05/03/2026 as May 3 (day-first is the default)
)defaultCurrency stamps fallback-matched rows only; see the honesty rule
in Working with results.preferMonthFirst only adds date shapes that day-first reading cannot
produce; it never reinterprets unambiguous dates.Built-in providers are ordinary template JSON loaded through the same registry path your own templates use, so an app can ship or download extra templates without code changes:
val mine = TemplateRegistry.fromTemplateJson(listOf(myTemplateJson1, myTemplateJson2))
val combined = TemplateRegistry(BuiltinTemplates.load() + mine.templates)Routing precedence is list order: the first template whose anchors match a
routed message wins. Higher revision values replace lower ones per template
id, and isActive: false retires an id, which is how shipped overrides and
community deprecations work without code changes.
senderId; the first template whose anchors match the body parses it.INCOMPLETE rather than
falling through.FallbackMatcher) produce a baseline row,
usually flagged for review.NO_ROUTE.The CONTRIBUTING guide maps the repository layout: engine, templates, fixtures, and playground.
Paste a sender and body on the hosted playground at https://peteretelej.github.io/sms-transaction-parser/ to see the parsed transaction or the exact failure reason. The report button opens a new GitHub issue with the message already pasted in.
Kotlin library that parses transaction SMS from mobile money and bank messages into structured data.
Hand it the sender ID, message body, and timestamp of one SMS; it returns a
ParsedTransaction (amount in minor units, type, merchant, reference,
balance, fee) or an explicit failure with a reason. It never returns null,
never implies a currency, and makes no network calls.
Under active development. Apache 2.0 - see LICENSE.
The parser is built for any market: currency detection is ISO-wide (every active ISO 4217 code, as of v0.2.0), and provider templates are plain JSON data on one engine. Coverage today comes in two honest tiers:
If your market is in the second tier, that is an invitation: adding a provider is template data plus sample fixtures, not parser code. See CONTRIBUTING.md for the workflow. The current template list is generated from the registry into docs/SUPPORTED_PROVIDERS.md.
The library targets the JVM in v1; Android apps consume the JVM jar.
Gradle (Kotlin DSL):
dependencies {
implementation("io.github.peteretelej:sms-transaction-parser:0.2.0")
}Maven:
<dependency>
<groupId>io.github.peteretelej</groupId>
<artifactId>sms-transaction-parser</artifactId>
<version>0.2.0</version>
</dependency>Parse one message:
import io.github.peteretelej.smstransactionparser.BuiltinTemplates
import io.github.peteretelej.smstransactionparser.ParseResult
import kotlin.time.Instant
val registry = BuiltinTemplates.defaultRegistry()
val result = registry.parse(
senderId = "MPESA",
body = "Confirmed. Ksh1,500.00 sent to JOHN DOE EXAMPLE 0749000001 on 05/01/26 at 10:20 AM. " +
"New M-PESA balance is Ksh8,500.00. Transaction cost, Ksh57.00.",
timestamp = Instant.parse("2026-01-05T10:20:30Z"),
)
when (result) {
is ParseResult.Success ->
println("${result.transaction.type} ${result.transaction.amountMinorUnits}")
is ParseResult.Failure ->
println("${result.reason}: ${result.detail}")
}registry.parse returns one of:
ParseResult.Success, carrying the ParsedTransaction and the templateId
that produced it (null for fallback rows, which no template produced).ParseResult.Failure, carrying a ParseFailureReason:| Reason | Meaning | What a consumer usually does |
|---|---|---|
NO_ROUTE |
Nothing parsed and the fallback leg did not run: the message is not transaction-shaped, or the registry was built with the fallback disabled. | Ignore; nothing was lost. |
NO_MATCH |
The message looks transaction-shaped, but no amount could be extracted. | Ignore, or treat like NO_ROUTE. |
INCOMPLETE |
A routed template's shape matched, but the amount is missing. | Review-worthy: the message belongs to a known provider shape. |
Every success also carries a ParseConfidence:
HIGH: count it as parsed.LOW: parsed, but reviewReason says why it needs a human (for example
"direction" or "amount"). Do not auto-count these. This is today the
only non-HIGH value registry.parse returns; MEDIUM and FAILED are
reserved enum values that parse does not produce.A transaction whose text names no currency keeps currency = null and
currencyDeclared = false. If you pass defaultCurrency, fallback-matched
rows with no named currency carry it - still flagged undeclared - while
template-parsed rows keep null; your totals stay honest about what the
message actually said.
All amounts are Long minor units scaled by the ISO 4217 exponent of the
currency: "Ksh1,500.00" parses to 150000 (KES exponent 2), a
zero-decimal currency like UGX parses to units, and exponentFor covers the
full ISO table (3-decimal currencies such as KWD return 3). No Double is
used anywhere in the pipeline. Render with the exponent, not by hard-coding
two decimals.
registry.parse(
senderId = sender,
body = body,
timestamp = timestamp,
defaultCurrency = "KES", // fallback rows with no named currency carry this
preferMonthFirst = true, // read 05/03/2026 as May 3 (day-first is the default)
)defaultCurrency stamps fallback-matched rows only; see the honesty rule
in Working with results.preferMonthFirst only adds date shapes that day-first reading cannot
produce; it never reinterprets unambiguous dates.Built-in providers are ordinary template JSON loaded through the same registry path your own templates use, so an app can ship or download extra templates without code changes:
val mine = TemplateRegistry.fromTemplateJson(listOf(myTemplateJson1, myTemplateJson2))
val combined = TemplateRegistry(BuiltinTemplates.load() + mine.templates)Routing precedence is list order: the first template whose anchors match a
routed message wins. Higher revision values replace lower ones per template
id, and isActive: false retires an id, which is how shipped overrides and
community deprecations work without code changes.
senderId; the first template whose anchors match the body parses it.INCOMPLETE rather than
falling through.FallbackMatcher) produce a baseline row,
usually flagged for review.NO_ROUTE.The CONTRIBUTING guide maps the repository layout: engine, templates, fixtures, and playground.
Paste a sender and body on the hosted playground at https://peteretelej.github.io/sms-transaction-parser/ to see the parsed transaction or the exact failure reason. The report button opens a new GitHub issue with the message already pasted in.