Skip to content

feat: add Pasargad/Wepod bank parser and tests#591

Open
rafi-ghanbari wants to merge 1 commit into
sarim2000:mainfrom
rafi-ghanbari:main
Open

feat: add Pasargad/Wepod bank parser and tests#591
rafi-ghanbari wants to merge 1 commit into
sarim2000:mainfrom
rafi-ghanbari:main

Conversation

@rafi-ghanbari

@rafi-ghanbari rafi-ghanbari commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for parsing SMS transaction notifications from Pasargad / Wepod Bank (Iran).

Type of change

  • feat: New feature
  • fix: Bug fix
  • docs: Documentation update
  • refactor: Code improvement (no behavior change)
  • perf: Performance improvement
  • style: Formatting (no behavior change)
  • test: Add/update tests
  • chore/ci: Build or CI changes

How to test

Run the Pasargad Bank parser unit tests to verify parsing rules:

./gradlew :parser-core:jvmTest --tests "com.pennywiseai.parser.core.bank.PasargadBankParserTest"

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds PasargadBankParser for Pasargad/Wepod Bank (Iran), wiring it into BankParserFactory and updating the documentation and JSON metadata to reflect 140 supported banks.

  • The parser uses a sign-based regex format (+/-amount, date, مانده: balance) distinct from the keyword-based approach in BaseIranianBankParser, and correctly overrides extractAmount, extractTransactionType, extractAccountLast4, and extractBalance.
  • isTransactionMessage bypasses the base class OTP/promo rejection guards; the pattern is specific enough to avoid false positives today, but a defensive super call would be safer.
  • extractAccountLast4 falls back to returning the raw dot-separated account string when stripped numeric digits are fewer than 4, rather than null, breaking the method's implied contract.

Confidence Score: 4/5

The new parser is straightforward and the regex correctly handles the multiline Pasargad SMS format; no data loss or misparse is expected for real-world messages.

The parser logic and tests cover the happy path well. Two defensive gaps — the OTP guard bypass and the dot-string fallback in extractAccountLast4 — are unlikely to cause problems with real Pasargad messages but worth addressing before the pattern is reused as a template for future parsers.

parser-core/src/main/kotlin/com/pennywiseai/parser/core/bank/PasargadBankParser.kt — the isTransactionMessage override and the extractAccountLast4 fallback branch both warrant a second look.

Important Files Changed

Filename Overview
parser-core/src/main/kotlin/com/pennywiseai/parser/core/bank/PasargadBankParser.kt New Pasargad/Wepod bank parser using a sign-based regex pattern; logic is sound but extractAccountLast4 has an inconsistent fallback and the OTP filter from the base class is not inherited.
parser-core/src/test/kotlin/com/pennywiseai/parser/core/bank/PasargadBankParserTest.kt Covers deposit, withdrawal, canHandle senders, factory resolution, and OTP ignore cases; the ignore test only covers messages that wouldn't match the regex anyway.
parser-core/src/main/kotlin/com/pennywiseai/parser/core/bank/BankParserFactory.kt PasargadBankParser correctly inserted between MellatBankParser and ParsianBankParser; no ordering conflicts with existing Iranian bank parsers.
docs/supported-banks.json Iran bank count updated from 5 to 6 and entry repositioned before UAE; totalBanks incremented to 140.
README.md Bank count updated from 139 to 140 and Pasargad Bank added to the Iran section; documentation is consistent with the JSON data.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant SMS as Incoming SMS
    participant Factory as BankParserFactory
    participant Pasargad as PasargadBankParser
    participant Base as BaseIranianBankParser

    SMS->>Factory: parse(smsBody, sender, timestamp)
    Factory->>Factory: getParsers(sender)
    Factory->>Pasargad: canHandle(sender)
    Note over Pasargad: Checks uppercase sender<br/>against {B.PASARGAD, PASARGAD, WEPOD}
    Pasargad-->>Factory: true
    Factory->>Pasargad: parse(smsBody, sender, timestamp)
    Pasargad->>Pasargad: isTransactionMessage(message)
    Note over Pasargad: Pattern match only<br/>(base OTP guards bypassed)
    Pasargad->>Pasargad: extractAmount(message)
    Note over Pasargad: Drop sign from group[2]<br/>parse BigDecimal
    Pasargad->>Pasargad: extractTransactionType(message)
    Note over Pasargad: '+' → INCOME / '-' → EXPENSE
    Pasargad->>Pasargad: extractAccountLast4(message)
    Note over Pasargad: Strip dots → takeLast(4)
    Pasargad->>Pasargad: extractBalance(message)
    Note over Pasargad: group[3] stripped of commas
    Pasargad->>Base: getCurrency() → IRR
    Pasargad-->>Factory: ParsedTransaction
    Factory-->>SMS: ParsedTransaction
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant SMS as Incoming SMS
    participant Factory as BankParserFactory
    participant Pasargad as PasargadBankParser
    participant Base as BaseIranianBankParser

    SMS->>Factory: parse(smsBody, sender, timestamp)
    Factory->>Factory: getParsers(sender)
    Factory->>Pasargad: canHandle(sender)
    Note over Pasargad: Checks uppercase sender<br/>against {B.PASARGAD, PASARGAD, WEPOD}
    Pasargad-->>Factory: true
    Factory->>Pasargad: parse(smsBody, sender, timestamp)
    Pasargad->>Pasargad: isTransactionMessage(message)
    Note over Pasargad: Pattern match only<br/>(base OTP guards bypassed)
    Pasargad->>Pasargad: extractAmount(message)
    Note over Pasargad: Drop sign from group[2]<br/>parse BigDecimal
    Pasargad->>Pasargad: extractTransactionType(message)
    Note over Pasargad: '+' → INCOME / '-' → EXPENSE
    Pasargad->>Pasargad: extractAccountLast4(message)
    Note over Pasargad: Strip dots → takeLast(4)
    Pasargad->>Pasargad: extractBalance(message)
    Note over Pasargad: group[3] stripped of commas
    Pasargad->>Base: getCurrency() → IRR
    Pasargad-->>Factory: ParsedTransaction
    Factory-->>SMS: ParsedTransaction
Loading

Reviews (1): Last reviewed commit: "feat: add Pasargad/Wepod bank parser and..." | Re-trigger Greptile

Comment on lines +59 to +62
if (pureNumeric.length >= 4) {
return pureNumeric.takeLast(4)
}
return fullAccount

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inconsistent fallback in extractAccountLast4: when pureNumeric.length < 4 the method returns fullAccount — a dot-separated string like "1.2.3" — rather than null. The method contract is "last 4 digits", so returning a non-numeric string with dots breaks callers that expect either digits-only or null.

Suggested change
if (pureNumeric.length >= 4) {
return pureNumeric.takeLast(4)
}
return fullAccount
if (pureNumeric.length >= 4) {
return pureNumeric.takeLast(4)
}
return null

Comment on lines +27 to +29
override fun isTransactionMessage(message: String): Boolean {
return pattern.containsMatchIn(message.trim())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 OTP/promo filter not inherited

BaseIranianBankParser.isTransactionMessage explicitly rejects messages containing OTP keywords ("otp", رمز یکبار مصرف, کد تایید) and promotional phrases before checking transaction keywords. The Pasargad override bypasses all of those guards. The pattern is specific enough that a false positive today is unlikely, but if Pasargad ever bundles OTP context alongside the numeric balance line (some banks do), the parser would treat it as a transaction. Consider running the base OTP/promo rejections first via a super call before the pattern check.

Comment on lines +91 to +104

return ignoredMessages.map { msg ->
DynamicTest.dynamicTest("Should ignore: ${msg.take(30)}...") {
org.junit.jupiter.api.Assertions.assertNull(parser.parse(msg, "B.Pasargad", System.currentTimeMillis()))
}
}
}

@TestFactory
fun `factory resolves pasargad bank`(): List<DynamicTest> {
val cases = listOf(
SimpleTestCase(
bankName = "Pasargad Bank",
sender = "B.Pasargad",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Ignore-test messages trivially non-matching

Both messages in the ignore test ("OTP verification" and "Your OTP code is 12345") don't contain مانده: or any signed numeric amount, so they would never match the parser's regex regardless. The test isn't actually verifying the OTP-rejection path — it only confirms that random non-Pasargad strings don't parse. A more meaningful case would be an OTP-style SMS from "B.PASARGAD" that happens to include a balance line, to confirm the parser handles that edge case correctly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@sarim2000

Copy link
Copy Markdown
Owner

Thanks for this, @rafi-ghanbari — parser and tests look solid on the happy path. 🙏 Greptile flagged three small hardening points I'd like tightened before merge (all minor, none blocking the core logic):

  1. extractAccountLast4 fallback (PasargadBankParser.kt:62) — when pureNumeric.length < 4 it returns fullAccount (a dot-separated string like 1.2.3) instead of null. The contract is "last 4 digits or null," so return null in that case (Greptile posted a suggestion inline).

  2. OTP/promo guard bypass (PasargadBankParser.kt:29) — the isTransactionMessage override skips BaseIranianBankParser's OTP/promo rejection (otp, رمز یکبار مصرف, کد تایید). Please delegate to / call super so those guards still apply — cheap insurance against a future false positive.

  3. OTP ignore test (PasargadBankParserTest.kt:104) — the two ignore-case messages don't contain مانده: or a signed amount, so they'd never match regardless; the test doesn't actually exercise the OTP-rejection path. Once Kotak Mahindra bank [BANK] #2 is in, add an ignore case that would otherwise look like a transaction but contains an OTP keyword.

Once those are addressed it's good to go. Appreciate the contribution!

@sarim2000

Copy link
Copy Markdown
Owner

Thanks for the parser + tests! The automated review flagged a few things to tighten up before merge:

  • [P2] PasargadBankParser.kt:62 — inconsistent fallback in extractAccountLast4.
  • [P2] PasargadBankParser.kt:29 — the OTP/promo filter isn’t inherited (promotional/OTP SMS may be parsed as transactions).
  • [P2] PasargadBankParserTest.kt:104 — the ignore-test messages are trivially non-matching, so they don’t really prove the filter works.

Could you address these, especially the OTP/promo filtering + a real negative test?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants