Lines 98.80% 83 / 84
Functions and Methods 85.71% 6 / 7
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
CsobMailParser 98.80% 83 / 84 85.71% 6 / 7 38 0.00% 0 / 1
 bankDomains 100.00% 1 / 1 100.00% 1 / 1 1
 supports 100.00% 3 / 3 100.00% 1 / 1 1
 parseMulti 100.00% 20 / 20 100.00% 1 / 1 6
 parseAll 100.00% 4 / 4 100.00% 1 / 1 2
 parse 100.00% 38 / 38 100.00% 1 / 1 14
 lastNumberInPurpose 100.00% 7 / 7 100.00% 1 / 1 5
 multilineSymbol 90.90% 10 / 11 0.00% 0 / 1 9.06
1<?php
2
3declare(strict_types=1);
4
5namespace Tomaj\BankMailsParser\Parser\Csob;
6
7use Tomaj\BankMailsParser\Exception\InvalidEmailFormatException;
8use Tomaj\BankMailsParser\MailContent;
9use Tomaj\BankMailsParser\Parser\BankDate;
10use Tomaj\BankMailsParser\Parser\ParserInterface;
11use Tomaj\BankMailsParser\Parser\ParsesBankMail;
12
13final readonly class CsobMailParser implements ParserInterface
14{
15    use ParsesBankMail;
16
17    /**
18     * A header is the start of a line. It does not need a blank line in front of it.
19     * A header inside a payer field is blanked with that field, so it does not open a transaction.
20     */
21    private const string TRANSACTION = '/^[ \t\x{00A0}\x{2009}\x{202F}]*Dne[ \t\x{00A0}\x{2009}\x{202F}]+(\d{1,2}\.\d{1,2}\.\d{4}(?:[ T\x{00A0}\x{2009}\x{202F}]\d{1,2}:\d{2})?)[ \t\x{00A0}\x{2009}\x{202F}]+byl(?:a)?[ \t\x{00A0}\x{2009}\x{202F}]+na účtu[ \t\x{00A0}\x{2009}\x{202F}]+([A-Za-z0-9]+)[ \t\x{00A0}\x{2009}\x{202F}]+(?:zaúčtovaná|zaúčtována|zaúčtovaný)[ \t\x{00A0}\x{2009}\x{202F}]+(?:zahraniční transakce\.|hotovostní transakce\.|(?:transakce typu:[ \t\x{00A0}\x{2009}\x{202F}]*|transakce[ \t\x{00A0}\x{2009}\x{202F}]+)?(?:Došlá platba|Příchozí úhrada|Došlá úhrada|SEPA převod|platební kartou))/mu';
22
23    private const string HEADER = '/^[ \t\x{00A0}\x{2009}\x{202F}]*Dne[ \t\x{00A0}\x{2009}\x{202F}]+\d{1,2}\.\d{1,2}\.\d{4}(?:[ T\x{00A0}\x{2009}\x{202F}]\d{1,2}:\d{2})?[ \t\x{00A0}\x{2009}\x{202F}]+byl(?:a)?[ \t\x{00A0}\x{2009}\x{202F}]+na účtu/mu';
24
25    private const string BALANCE = '/Zůstatek na účtu po zaúčtování transakce:[ \t\x{00A0}\x{2009}\x{202F}]*([+-])[ \t\x{00A0}\x{2009}\x{202F}]*((?:\d{1,3}(?:[ \t\x{00A0}\x{2009}\x{202F}]\d{3})*|\d+),\d{2})[ \t\x{00A0}\x{2009}\x{202F}]+([A-Z]{3})\./u';
26
27    private const string ACCOUNT = '[A-Za-z0-9][A-Za-z0-9 \t\x{00A0}\x{2009}\x{202F}\\/-]*';
28
29    /**
30     * @return list<string>
31     */
32    #[\Override]
33    public function bankDomains(): array
34    {
35        return ['csob.cz'];
36    }
37
38    #[\Override]
39    public function supports(string $content, ?string $sender = null, ?string $subject = null): bool
40    {
41        return $this->supportsQuietly(
42            fn(): bool => $this->matched(self::HEADER, $this->withoutFreeText($this->normalizeHtmlLineBreaks($this->withoutByteOrderMark($content)))),
43        );
44    }
45
46    /**
47     * @return list<MailContent>
48     */
49    public function parseMulti(string $content): array
50    {
51        $content = $this->normalizeHtmlLineBreaks($this->requireUniformLineBreaks($content));
52        $chunks = $this->splitOutsideFreeText($content, self::HEADER);
53        $parsed = [];
54        $balances = [];
55        $errors = [];
56        $first = null;
57        $index = 0;
58        foreach ($chunks as $chunk) {
59            if (!$this->matched(self::TRANSACTION, $this->withoutFreeText($chunk))) {
60                continue;
61            }
62            ++$index;
63            try {
64                $parsed[] = $this->parse($chunk);
65                $balances[] = $this->balanceAmount($chunk, self::BALANCE);
66            } catch (InvalidEmailFormatException $exception) {
67                $first ??= $exception;
68                $errors[] = sprintf('transaction %d: %s', $index, $exception->getMessage());
69            }
70        }
71
72        $parsed = $this->transactionsOrFail($parsed, $errors, $first);
73        if (count($parsed) > 1 && $this->matched(self::BALANCE, $content)) {
74            $this->assertBalanceChain($balances, $parsed);
75        }
76
77        return $parsed;
78    }
79
80    #[\Override]
81    public function parseAll(string $content): array
82    {
83        $parsed = $this->parseMulti($content);
84        if ($parsed === []) {
85            throw InvalidEmailFormatException::malformedContent(self::class, 'Could not find transaction pattern');
86        }
87
88        return $parsed;
89    }
90
91    public function parse(string $content): MailContent
92    {
93        $original = $this->normalizeHtmlLineBreaks($this->requireUniformLineBreaks($content));
94        $receiverMessage = $this->lineValue($original, 'Zpráva příjemci');
95        $content = $this->withoutFreeText($original);
96        if (!$this->matched(self::TRANSACTION, $content, $result)) {
97            throw InvalidEmailFormatException::malformedContent(self::class, 'Could not find transaction pattern');
98        }
99
100        $transactionDate = BankDate::parseFirst($this->group($result, 1), ['!j.n.Y G:i', '!j.n.Y'], BankDate::bratislava());
101        $accountNumber = $this->group($result, 2);
102
103        $sourceAccountNumber = null;
104        if ($this->matched($this->field('Účet protistrany(?:\/IBAN)*:', '(' . self::ACCOUNT . ')'), $content, $counterparty)) {
105            $sourceAccountNumber = mb_trim($this->group($counterparty, 1), " \t\n\r\0\x0B\u{00A0}\u{2009}\u{202F}");
106        }
107
108        $money = null;
109        if ($this->matched($this->field('Částka:', '([+-])[ \t\x{00A0}\x{2009}\x{202F}]*(.*?)[ \t\x{00A0}\x{2009}\x{202F}]+([A-Z]{3})\b'), $content, $amount)) {
110            $signed = ($this->group($amount, 1) === '-' ? '-' : '') . $this->group($amount, 2);
111            $money = $this->moneyOrNull($signed, $this->group($amount, 3), ',');
112        }
113
114        $vs = $this->multilineSymbol($original, 'variable');
115        $identified = $this->multilineSymbol($original, 'identification');
116        if ($identified !== null) {
117            $vs = $identified;
118        }
119        // 4.1.0 fallbacks. Payer text can supply vs only. Amount, account, and date stay on the bank lines.
120        if ($vs === null && $this->matched('/vs([0-9]{1,10})(?!\d)/i', $original, $symbol)) {
121            $vs = $this->group($symbol, 1);
122        }
123        if ($vs === null && $this->matched('/v\.s\.([0-9]{1,10})(?!\d)/i', $original, $symbol)) {
124            $vs = $this->group($symbol, 1);
125        }
126        if ($vs === null && $this->matched('/Zpráva příjemci:.*\b([0-9]{1,10})\b.*/i', $original, $symbol)) {
127            $vs = $this->group($symbol, 1);
128        }
129        if ($vs === null) {
130            $vs = $this->multilineSymbol($original, 'purpose') ?? $this->lastNumberInPurpose($original);
131        }
132
133        $ks = null;
134        if ($this->matched($this->field('Konstantní symbol:', '([0-9]+)'), $original, $symbol)) {
135            $ks = $this->paymentSymbol($this->group($symbol, 1));
136        }
137
138        return new MailContent(
139            money: $money,
140            transactionDate: $transactionDate,
141            accountNumber: $accountNumber,
142            sourceAccountNumber: $sourceAccountNumber,
143            vs: $vs,
144            ks: $ks,
145            receiverMessage: $receiverMessage,
146        );
147    }
148
149    /**
150     * Last 1–10 digit number between Účel platby and Zůstatek, as in 4.1.0.
151     * Without Zůstatek the footer is ignored. A number glued to letters is not a symbol.
152     * A blank line does not stop the search: 4.1.0 uses dotall up to Zůstatek.
153     */
154    private function lastNumberInPurpose(string $original): ?string
155    {
156        if (!str_contains($original, 'Zůstatek') || preg_match('/Účel platby:(.*?)Zůstatek/su', $original, $match) !== 1) {
157            return null;
158        }
159        $text = $match[1];
160        if (preg_match_all('/\b([0-9]{1,10})\b/u', $text, $numbers) < 1) {
161            return null;
162        }
163
164        $last = $numbers[1][array_key_last($numbers[1])] ?? null;
165
166        return is_string($last) ? $last : null;
167    }
168
169    /**
170     * `/m` on these patterns has no effect: they do not use `^` or `$`.
171     * Infection ignores PregMatchRemoveFlags for this method only.
172     */
173    private function multilineSymbol(string $original, string $which): ?string
174    {
175        $pattern = match ($which) {
176            'variable' => '/Variabilní symbol: ([0-9]{1,10})(?!\d)/m',
177            'identification' => '/Identifikace: ([0-9]{1,10})(?!\d)/m',
178            'purpose' => '/Účel platby: ([0-9]{1,10})(?!\d)/m',
179            default => null,
180        };
181        if ($pattern === null || !$this->matched($pattern, $original, $symbol)) {
182            return null;
183        }
184        $value = $this->group($symbol, 1);
185        if ($which === 'variable' && (int) $value === 0) {
186            return null;
187        }
188
189        return $value;
190    }
191}