Lines 96.29% 52 / 54
Functions and Methods 87.50% 7 / 8
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
TatraBankaStatementMailParser 96.29% 52 / 54 87.50% 7 / 8 24 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 bankDomains 100.00% 1 / 1 100.00% 1 / 1 1
 supports 100.00% 3 / 3 100.00% 1 / 1 1
 parseMulti 100.00% 1 / 1 100.00% 1 / 1 1
 parse 100.00% 1 / 1 100.00% 1 / 1 1
 parseAll 100.00% 4 / 4 100.00% 1 / 1 2
 decryptRows 93.33% 28 / 30 0.00% 0 / 1 11.04
 parseRow 100.00% 13 / 13 100.00% 1 / 1 6
1<?php
2
3declare(strict_types=1);
4
5namespace Tomaj\BankMailsParser\Parser\TatraBanka;
6
7use Tomaj\BankMailsParser\Exception\ConfigurationException;
8use Tomaj\BankMailsParser\Exception\DecryptionException;
9use Tomaj\BankMailsParser\Exception\InvalidEmailFormatException;
10use Tomaj\BankMailsParser\MailContent;
11use Tomaj\BankMailsParser\Parser\BankDate;
12use Tomaj\BankMailsParser\Parser\ParserInterface;
13use Tomaj\BankMailsParser\Parser\ParsesBankMail;
14
15final readonly class TatraBankaStatementMailParser implements ParserInterface
16{
17    use ParsesBankMail;
18
19    public function __construct(
20        private ?TatraBankaMailDecryptor $decryptor = null,
21    ) {}
22
23    /**
24     * @return list<string>
25     */
26    #[\Override]
27    public function bankDomains(): array
28    {
29        return ['tatrabanka.sk'];
30    }
31
32    #[\Override]
33    public function supports(string $content, ?string $sender = null, ?string $subject = null): bool
34    {
35        return $this->supportsQuietly(
36            fn(): bool => $this->matched('/^-----BEGIN PGP MESSAGE-----/m', $this->withoutFreeText($this->withoutByteOrderMark($content))),
37        );
38    }
39
40    /**
41     * @return list<MailContent>
42     */
43    public function parseMulti(string $content): array
44    {
45        return $this->parseAll($content);
46    }
47
48    public function parse(string $content): MailContent
49    {
50        return $this->parseAll($content)[0];
51    }
52
53    #[\Override]
54    public function parseAll(string $content): array
55    {
56        $content = $this->scrubUtf8($this->requireUniformLineBreaks($content));
57        if (str_contains($content, 'BEGIN PGP MESSAGE')) {
58            return $this->decryptRows($content);
59        }
60
61        return [$this->parseRow($content)];
62    }
63
64    /**
65     * @return list<MailContent>
66     */
67    private function decryptRows(string $content): array
68    {
69        if (!$this->decryptor instanceof TatraBankaMailDecryptor) {
70            throw new ConfigurationException('PGP decryptor is not configured.');
71        }
72
73        if (!$this->matched('/(-{5}BEGIN[A-Za-z0-9 \-\r\n+\/=]+END PGP MESSAGE-{5})/m', $content, $results)) {
74            throw InvalidEmailFormatException::malformedContent(self::class, 'Encrypted statement could not be read');
75        }
76
77        try {
78            $decrypted = $this->decryptor->decrypt($this->group($results, 0));
79        } catch (ConfigurationException $exception) {
80            throw $exception;
81        } catch (DecryptionException $exception) {
82            throw InvalidEmailFormatException::malformedContent(self::class, 'Encrypted statement could not be read', $exception);
83        }
84
85        $split = preg_split("/\r\n|\n|\r/", $decrypted);
86        if ($split === false) {
87            throw InvalidEmailFormatException::malformedContent(self::class, 'Encrypted statement could not be read');
88        }
89
90        $parsed = [];
91        $errors = [];
92        $rowNumber = 0;
93        foreach (array_slice($split, 1) as $transaction) {
94            if (trim($transaction) === '') {
95                continue;
96            }
97            ++$rowNumber;
98            try {
99                $parsed[] = $this->parseRow($transaction);
100            } catch (InvalidEmailFormatException $exception) {
101                $errors[] = sprintf('row %d: %s', $rowNumber, $exception->getMessage());
102            }
103        }
104
105        if ($errors !== []) {
106            throw InvalidEmailFormatException::malformedContent(
107                self::class,
108                'Could not parse every transaction: ' . implode('; ', $errors),
109            );
110        }
111
112        if ($parsed === []) {
113            throw InvalidEmailFormatException::malformedContent(self::class, 'Encrypted statement could not be read');
114        }
115
116        return $parsed;
117    }
118
119    private function parseRow(string $content): MailContent
120    {
121        $cols = array_filter(explode('|', $content), static fn(string $value): bool => $value !== '' && $value !== '0');
122        if ($cols === []) {
123            throw InvalidEmailFormatException::malformedContent(self::class, 'No valid columns found');
124        }
125
126        if (!isset($cols[9], $cols[10])) {
127            throw InvalidEmailFormatException::missingRequiredField(self::class, 'amount/currency');
128        }
129
130        $money = $this->money($cols[9], trim($cols[10]), '.');
131        $transactionDate = isset($cols[0]) ? BankDate::parse($cols[0], '!Ymd', BankDate::bratislava()) : null;
132
133        return new MailContent(
134            money: $money,
135            transactionDate: $transactionDate,
136            vs: $cols[18] ?? null,
137            // Column 19 is the statement header "Číslo účtu": the merchant account
138            // credited for this card sale. It can differ on every row.
139            accountNumber: isset($cols[19]) ? $this->normalizeAccount($cols[19]) : null,
140        );
141    }
142}