Lines
96.32%
236 / 245
Methods
86.04%
37 / 43
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __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 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] parse | n/a | 0 / 0 | n/a | 0 / 0 | 0 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] tryParse | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 4 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] parseAll | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] withoutByteOrderMark | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] requireUniformLineBreaks | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] normalizeHtmlLineBreaks | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 3 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] scrubUtf8 | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] supportsQuietly | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] money | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] moneyOrNull | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 4 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] matched | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 3 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] group | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] optionalGroup | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 3 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] normalizeAccount | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] transactionsOrFail | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] senderDomain | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] bankDomains | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] withoutFreeText | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] assertBalanceChain | 78.57% | 11 / 14 | 0.00% | 0 / 1 | 10.98 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] balanceAmount | 90.00% | 9 / 10 | 0.00% | 0 / 1 | 5.03 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] maskFreeText | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] splitOutsideFreeText | 92.85% | 13 / 14 | 0.00% | 0 / 1 | 7.02 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] field | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] lineValue | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 3 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] paymentSymbol | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 5 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] rewriteFreeText | 97.14% | 34 / 35 | 0.00% | 0 / 1 | 20 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] isFreeTextLabel | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] payerKind | 100.00% | 13 / 13 | 100.00% | 1 / 1 | 12 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] leadingLabel | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] isBlankLine | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] couldBeBankLine | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 4 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] isBankCloser | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 6 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] isExactBalanceLine | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] isDigitSymbolLine | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] isExactAccountLine | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 1 | ||
| [Tomaj\BankMailsParser\Parser\ParsesBankMail] isTatraBalanceLine | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| 15 | final 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 | } |
From Tomaj\BankMailsParser\Parser\ParsesBankMail
| 14 | trait ParsesBankMail | |
| 15 | { | |
| 16 | abstract public function parse(string $content): MailContent; | |
| 17 | ||
| 18 | /** | |
| 19 | * Invalid input returns null. ConfigurationException is a setup error and is rethrown. | |
| 20 | * TypeError and Error are programmer errors and are not caught. | |
| 21 | */ | |
| 22 | public function tryParse(string $content): ?MailContent | |
| 23 | { | |
| 24 | try { | |
| 25 | return $this->parse($content); | |
| 26 | } catch (ConfigurationException $exception) { | |
| 27 | throw $exception; | |
| 28 | } catch (ParseException) { | |
| 29 | return null; | |
| 30 | } catch (\InvalidArgumentException|\ValueError|\DateMalformedStringException) { | |
| 31 | return null; | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * @return list<MailContent> | |
| 37 | */ | |
| 38 | public function parseAll(string $content): array | |
| 39 | { | |
| 40 | return [$this->parse($content)]; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * Drop a leading BOM. CRLF becomes LF. Lone CR, VT, FF, U+0085, U+2028 and U+2029 | |
| 45 | * become LF only when the mail has no LF at all. A mail that already has LF keeps | |
| 46 | * those characters as ordinary text: they do not split a line and they do not open | |
| 47 | * a new transaction. supports() uses this and does not throw. | |
| 48 | * | |
| 49 | * The `/m` flag is required: a BOM after a header is still a BOM. | |
| 50 | * | |
| 51 | * @internal | |
| 52 | */ | |
| 53 | protected function withoutByteOrderMark(string $content): string | |
| 54 | { | |
| 55 | $stripped = preg_replace('/^\x{FEFF}/mu', '', $content); | |
| 56 | $content = is_string($stripped) ? $stripped : $content; | |
| 57 | $content = str_replace("\r\n", "\n", $content); | |
| 58 | if (str_contains($content, "\n")) { | |
| 59 | return $content; | |
| 60 | } | |
| 61 | ||
| 62 | return str_replace(["\r", "\v", "\f", "\u{0085}", "\u{2028}", "\u{2029}"], "\n", $content); | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Normalize line breaks. Exotic separators stay in a mail that already has LF. | |
| 67 | * They are ordinary characters there and cannot open a transaction. | |
| 68 | * | |
| 69 | * @internal | |
| 70 | */ | |
| 71 | protected function requireUniformLineBreaks(string $content): string | |
| 72 | { | |
| 73 | return $this->withoutByteOrderMark($content); | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * A single-line HTML ČSOB body uses `<br>`, `</p>` and `</div>` as line breaks. | |
| 78 | * Turn those into newlines, then drop the remaining tags, so a header can still | |
| 79 | * start a line. `<br>` does not stay inside an account number. | |
| 80 | * | |
| 81 | * @internal | |
| 82 | */ | |
| 83 | protected function normalizeHtmlLineBreaks(string $content): string | |
| 84 | { | |
| 85 | $broken = preg_replace('/<br\s*\/?>|<\/(?:p|div|tr)\s*>|<(?:p|div|tr)\b[^>]*>/iu', "\n", $content); | |
| 86 | $content = is_string($broken) ? $broken : $content; | |
| 87 | $stripped = preg_replace('/<[^>\n]*>/', '', $content); | |
| 88 | ||
| 89 | return is_string($stripped) ? $stripped : $content; | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Tatra and CardPay patterns use `/u`. Invalid bytes are replaced so a | |
| 94 | * Windows-1250 body still parses, the way 4.x did without `/u`. | |
| 95 | * | |
| 96 | * @internal | |
| 97 | */ | |
| 98 | protected function scrubUtf8(string $content): string | |
| 99 | { | |
| 100 | if (mb_check_encoding($content, 'UTF-8')) { | |
| 101 | return $content; | |
| 102 | } | |
| 103 | ||
| 104 | return mb_scrub($content, 'UTF-8'); | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * supports() never throws. A PCRE limit on a huge mail is "not this bank". | |
| 109 | * | |
| 110 | * @param \Closure(): bool $probe | |
| 111 | * | |
| 112 | * @internal | |
| 113 | */ | |
| 114 | protected function supportsQuietly(\Closure $probe): bool | |
| 115 | { | |
| 116 | try { | |
| 117 | return $probe(); | |
| 118 | } catch (\Throwable) { | |
| 119 | return false; | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * Turn a decimal string into Money. Invalid amounts become InvalidEmailFormatException. | |
| 125 | */ | |
| 126 | protected function money(string $amount, Currency|string $currency, ?string $decimalSeparator = null): Money | |
| 127 | { | |
| 128 | try { | |
| 129 | return Money::fromDecimalString($amount, $currency, $decimalSeparator); | |
| 130 | } catch (\InvalidArgumentException $exception) { | |
| 131 | throw InvalidEmailFormatException::malformedContent(static::class, $exception->getMessage(), $exception); | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * Same as money(), except a missing, unreadable, or unknown-currency amount is null. | |
| 137 | * The rest of the transaction is still returned, as in 4.x. | |
| 138 | */ | |
| 139 | protected function moneyOrNull(string $amount, Currency|string $currency, ?string $decimalSeparator = null): ?Money | |
| 140 | { | |
| 141 | if (is_string($currency) && Currency::tryFromCode($currency) === null) { | |
| 142 | return null; | |
| 143 | } | |
| 144 | ||
| 145 | try { | |
| 146 | return $this->money($amount, $currency, $decimalSeparator); | |
| 147 | } catch (InvalidEmailFormatException) { | |
| 148 | return null; | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * @param array<int|string, string>|null $matches | |
| 154 | * | |
| 155 | * @internal | |
| 156 | */ | |
| 157 | protected function matched(string $pattern, string $subject, ?array &$matches = null): bool | |
| 158 | { | |
| 159 | $captured = []; | |
| 160 | $result = preg_match($pattern, $subject, $captured); | |
| 161 | if ($result === false) { | |
| 162 | throw InvalidEmailFormatException::malformedContent(static::class, 'Regular expression failed: ' . preg_last_error_msg()); | |
| 163 | } | |
| 164 | ||
| 165 | if ($result === 1) { | |
| 166 | /** @var array<int|string, string> $captured */ | |
| 167 | $matches = $captured; | |
| 168 | ||
| 169 | return true; | |
| 170 | } | |
| 171 | ||
| 172 | return false; | |
| 173 | } | |
| 174 | ||
| 175 | /** | |
| 176 | * @param array<int|string, string>|null $matches | |
| 177 | * | |
| 178 | * @internal | |
| 179 | */ | |
| 180 | protected function group(?array $matches, int $index): string | |
| 181 | { | |
| 182 | $value = $matches[$index] ?? null; | |
| 183 | if (!is_string($value)) { | |
| 184 | throw InvalidEmailFormatException::malformedContent(static::class, 'Missing regular expression capture'); | |
| 185 | } | |
| 186 | ||
| 187 | return $value; | |
| 188 | } | |
| 189 | ||
| 190 | /** | |
| 191 | * @param array<int|string, string>|null $matches | |
| 192 | * | |
| 193 | * @internal | |
| 194 | */ | |
| 195 | private function optionalGroup(?array $matches, int $index): ?string | |
| 196 | { | |
| 197 | $value = $matches[$index] ?? null; | |
| 198 | if (!is_string($value) || $value === '') { | |
| 199 | return null; | |
| 200 | } | |
| 201 | ||
| 202 | return $value; | |
| 203 | } | |
| 204 | ||
| 205 | /** @internal */ | |
| 206 | private function normalizeAccount(string $value): string | |
| 207 | { | |
| 208 | $spaced = str_replace(["\u{00A0}", "\u{2009}", "\u{202F}", "\t"], ' ', $value); | |
| 209 | $collapsed = preg_replace('/ {2,}/', ' ', trim($spaced)); | |
| 210 | ||
| 211 | return is_string($collapsed) ? $collapsed : trim($spaced); | |
| 212 | } | |
| 213 | ||
| 214 | /** | |
| 215 | * @param list<MailContent> $parsed | |
| 216 | * @param list<string> $errors | |
| 217 | * | |
| 218 | * @return list<MailContent> | |
| 219 | * | |
| 220 | * @internal | |
| 221 | */ | |
| 222 | private function transactionsOrFail(array $parsed, array $errors, ?\Throwable $previous = null): array | |
| 223 | { | |
| 224 | if ($errors !== []) { | |
| 225 | throw InvalidEmailFormatException::malformedContent( | |
| 226 | static::class, | |
| 227 | 'Could not parse every transaction: ' . implode('; ', $errors), | |
| 228 | $previous, | |
| 229 | ); | |
| 230 | } | |
| 231 | ||
| 232 | return $parsed; | |
| 233 | } | |
| 234 | ||
| 235 | /** @internal */ | |
| 236 | protected function senderDomain(string $sender, string $domain): bool | |
| 237 | { | |
| 238 | return SenderHost::matches($sender, $domain); | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * @return list<string> | |
| 243 | */ | |
| 244 | public function bankDomains(): array | |
| 245 | { | |
| 246 | return []; | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * Payer-controlled text. Amount, currency, account and date are never read from it. | |
| 251 | * | |
| 252 | * @internal | |
| 253 | */ | |
| 254 | protected function withoutFreeText(string $content): string | |
| 255 | { | |
| 256 | return $this->rewriteFreeText($content, false); | |
| 257 | } | |
| 258 | ||
| 259 | /** | |
| 260 | * When a ČSOB mail has more than one transaction and at least one balance line, | |
| 261 | * each transaction must carry a balance and balance[n-1] + amount[n] = balance[n]. | |
| 262 | * | |
| 263 | * @param list<?Money> $balances | |
| 264 | * @param list<MailContent> $parsed | |
| 265 | * | |
| 266 | * @internal | |
| 267 | */ | |
| 268 | private function assertBalanceChain(array $balances, array $parsed): void | |
| 269 | { | |
| 270 | $count = count($parsed); | |
| 271 | for ($index = 0; $index < $count; ++$index) { | |
| 272 | if (!$balances[$index] instanceof Money) { | |
| 273 | throw InvalidEmailFormatException::malformedContent(static::class, 'Transaction is missing a balance line'); | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | for ($index = 1; $index < $count; ++$index) { | |
| 278 | $previous = $balances[$index - 1]; | |
| 279 | $current = $balances[$index]; | |
| 280 | $amount = $parsed[$index]->money; | |
| 281 | if (!$previous instanceof Money || !$current instanceof Money || !$amount instanceof Money) { | |
| 282 | throw InvalidEmailFormatException::malformedContent(static::class, 'Balance does not match the previous transaction'); | |
| 283 | } | |
| 284 | if (!$previous->currency->equals($current->currency) || !$previous->currency->equals($amount->currency)) { | |
| 285 | throw InvalidEmailFormatException::malformedContent(static::class, 'Balance does not match the previous transaction'); | |
| 286 | } | |
| 287 | if ($previous->amount + $amount->amount !== $current->amount) { | |
| 288 | throw InvalidEmailFormatException::malformedContent(static::class, 'Balance does not match the previous transaction'); | |
| 289 | } | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * The bank prints one balance at the end of the transaction. A second exact | |
| 295 | * balance line in the same chunk is rejected. | |
| 296 | * | |
| 297 | * @internal | |
| 298 | */ | |
| 299 | private function balanceAmount(string $chunk, string $pattern): ?Money | |
| 300 | { | |
| 301 | $found = preg_match_all($pattern, $chunk, $matches, PREG_SET_ORDER); | |
| 302 | if ($found === false) { | |
| 303 | throw InvalidEmailFormatException::malformedContent(static::class, 'Regular expression failed: ' . preg_last_error_msg()); | |
| 304 | } | |
| 305 | if ($found > 1) { | |
| 306 | throw InvalidEmailFormatException::malformedContent(static::class, 'Transaction has more than one balance line'); | |
| 307 | } | |
| 308 | if ($found !== 1) { | |
| 309 | return null; | |
| 310 | } | |
| 311 | ||
| 312 | $row = $matches[0]; | |
| 313 | $signed = (($row[1] ?? '') === '-' ? '-' : '') . ($row[2] ?? ''); | |
| 314 | ||
| 315 | return $this->money($signed, $row[3] ?? '', ','); | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * Same byte length as $content. Free-text lines become spaces so a later match offset stays valid. | |
| 320 | * | |
| 321 | * @internal | |
| 322 | */ | |
| 323 | private function maskFreeText(string $content): string | |
| 324 | { | |
| 325 | return $this->rewriteFreeText($content, true); | |
| 326 | } | |
| 327 | ||
| 328 | /** | |
| 329 | * @return list<string> | |
| 330 | * | |
| 331 | * @internal | |
| 332 | */ | |
| 333 | private function splitOutsideFreeText(string $content, string $pattern): array | |
| 334 | { | |
| 335 | $masked = $this->maskFreeText($content); | |
| 336 | $found = preg_match_all($pattern, $masked, $matches, PREG_OFFSET_CAPTURE); | |
| 337 | if ($found === false || $found === 0) { | |
| 338 | return [$content]; | |
| 339 | } | |
| 340 | ||
| 341 | $chunks = []; | |
| 342 | $total = count($matches[0]); | |
| 343 | for ($index = 0; $index < $total; ++$index) { | |
| 344 | $start = $matches[0][$index][1] ?? null; | |
| 345 | $next = $matches[0][$index + 1][1] ?? null; | |
| 346 | if (!is_int($start)) { | |
| 347 | continue; | |
| 348 | } | |
| 349 | $end = is_int($next) ? $next : strlen($content); | |
| 350 | $chunks[] = substr($content, $start, $end - $start); | |
| 351 | } | |
| 352 | ||
| 353 | return $chunks === [] ? [$content] : $chunks; | |
| 354 | } | |
| 355 | ||
| 356 | /** | |
| 357 | * A label at the start of a line, with the shared whitespace class on both sides. | |
| 358 | * One builder, so a duplicated `*` class is not copied into every parser. | |
| 359 | * | |
| 360 | * @internal | |
| 361 | */ | |
| 362 | private function field(string $label, string $value): string | |
| 363 | { | |
| 364 | $space = MailWhitespace::WS; | |
| 365 | ||
| 366 | return '/^' . $space . $label . $space . $value . '/mu'; | |
| 367 | } | |
| 368 | ||
| 369 | /** @internal */ | |
| 370 | private function lineValue(string $content, string $labelPattern): ?string | |
| 371 | { | |
| 372 | if (!$this->matched($this->field($labelPattern . ':', '(.*)$'), $content, $matches)) { | |
| 373 | return null; | |
| 374 | } | |
| 375 | ||
| 376 | $value = mb_trim($this->group($matches, 1), " \t\n\r\0\x0B\u{00A0}\u{2009}\u{202F}"); | |
| 377 | ||
| 378 | return $value === '' ? null : $value; | |
| 379 | } | |
| 380 | ||
| 381 | /** @internal */ | |
| 382 | private function paymentSymbol(string $digits, ?int $maxDigits = 10): ?string | |
| 383 | { | |
| 384 | if ($digits === '' || preg_match('/^\d+$/', $digits) !== 1) { | |
| 385 | return null; | |
| 386 | } | |
| 387 | if ($maxDigits !== null && strlen($digits) > $maxDigits) { | |
| 388 | return null; | |
| 389 | } | |
| 390 | ||
| 391 | return $digits; | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * Payer-written fields. | |
| 396 | * | |
| 397 | * ČSOB CZ: Název protistrany, Adresa protistrany, Adresa plátce, Zpráva příjemci, | |
| 398 | * Účel platby, Identifikace, Reference plátce. | |
| 399 | * ČSOB SK: názov protiúčtu, meno protiúčtu, informácia pre príjemcu, referencia platiteľa. | |
| 400 | * Tatra: Informacia pre prijemcu, Popis transakcie, popis, Referencia platitela. | |
| 401 | * VÚB fixtures have no payer prose field. | |
| 402 | * | |
| 403 | * A blank line, or a line of spaces, tabs or NBSP, ends the field. | |
| 404 | * An arbitrary "Word:" does not. A closing line has to be the bank's own shape: | |
| 405 | * the full balance sentence, a digits-only symbol line, an account line, or a Tatra balance line. | |
| 406 | * Lines inside the field are blanked when bank data is read. They do not reject the mail. | |
| 407 | */ | |
| 408 | private function rewriteFreeText(string $content, bool $mask): string | |
| 409 | { | |
| 410 | $parts = preg_split('/(\n)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE); | |
| 411 | if ($parts === false) { | |
| 412 | return $content; | |
| 413 | } | |
| 414 | ||
| 415 | $active = null; | |
| 416 | $out = ''; | |
| 417 | foreach ($parts as $part) { | |
| 418 | if ($part === "\n") { | |
| 419 | if ($mask || $active === null) { | |
| 420 | $out .= $part; | |
| 421 | } | |
| 422 | ||
| 423 | continue; | |
| 424 | } | |
| 425 | ||
| 426 | if ($active !== null && $this->isBlankLine($part)) { | |
| 427 | $active = null; | |
| 428 | $out .= $part; | |
| 429 | ||
| 430 | continue; | |
| 431 | } | |
| 432 | ||
| 433 | // A continuation such as "a" cannot be a label, a closer, or a forged bank line. | |
| 434 | // Skipping the regexes keeps a long purpose field from scanning every pattern per line. | |
| 435 | if ($active !== null && !$this->couldBeBankLine($part)) { | |
| 436 | $out .= $mask ? str_repeat(' ', strlen($part)) : ''; | |
| 437 | ||
| 438 | continue; | |
| 439 | } | |
| 440 | ||
| 441 | $next = $this->payerKind($part); | |
| 442 | if ($active !== null && $next !== null) { | |
| 443 | $active = $next; | |
| 444 | $out .= $mask ? str_repeat(' ', strlen($part)) : ''; | |
| 445 | ||
| 446 | continue; | |
| 447 | } | |
| 448 | ||
| 449 | if ($active !== null && $this->isBankCloser($part, $active)) { | |
| 450 | $active = null; | |
| 451 | $out .= $part; | |
| 452 | ||
| 453 | continue; | |
| 454 | } | |
| 455 | ||
| 456 | if ($next !== null) { | |
| 457 | $active = $next; | |
| 458 | $out .= $mask ? str_repeat(' ', strlen($part)) : ''; | |
| 459 | ||
| 460 | continue; | |
| 461 | } | |
| 462 | ||
| 463 | if ($active !== null) { | |
| 464 | $out .= $mask ? str_repeat(' ', strlen($part)) : ''; | |
| 465 | ||
| 466 | continue; | |
| 467 | } | |
| 468 | ||
| 469 | $out .= $part; | |
| 470 | } | |
| 471 | ||
| 472 | return $out; | |
| 473 | } | |
| 474 | ||
| 475 | /** @internal */ | |
| 476 | private function isFreeTextLabel(string $line): bool | |
| 477 | { | |
| 478 | return $this->payerKind($line) !== null; | |
| 479 | } | |
| 480 | ||
| 481 | private function payerKind(string $line): ?string | |
| 482 | { | |
| 483 | $label = $this->leadingLabel($line); | |
| 484 | if ($label === null) { | |
| 485 | return null; | |
| 486 | } | |
| 487 | ||
| 488 | return match (true) { | |
| 489 | preg_match('/^(?:název protistrany|adresa plátce|adresa platcu|názov protiúčtu|meno protiúčtu)$/ui', $label) === 1 => 'name', | |
| 490 | preg_match('/^adresa protistrany$/ui', $label) === 1 => 'address', | |
| 491 | preg_match('/^zpráva příjemci$/ui', $label) === 1 => 'message', | |
| 492 | preg_match('/^účel platby$/ui', $label) === 1 => 'purpose', | |
| 493 | preg_match('/^identifikace$/ui', $label) === 1 => 'identification', | |
| 494 | preg_match('/^informácia pre príjemcu$/ui', $label) === 1 => 'sk-message', | |
| 495 | preg_match('/^informacia pre prijemcu$/ui', $label) === 1 => 'tatra-message', | |
| 496 | preg_match('/^(?:popis transakcie|popis)$/ui', $label) === 1 => 'tatra-description', | |
| 497 | preg_match('/^(?:reference plátce|referencia platiteľa|referencia platitela)$/ui', $label) === 1 => 'reference', | |
| 498 | default => null, | |
| 499 | }; | |
| 500 | } | |
| 501 | ||
| 502 | /** | |
| 503 | * Possessive leading whitespace: a multi-megabyte line of spaces must not backtrack. | |
| 504 | */ | |
| 505 | private function leadingLabel(string $line): ?string | |
| 506 | { | |
| 507 | if (preg_match('/^[ \t\x{00A0}\x{2009}\x{202F}\x{FEFF}]*+([^\n:]{1,80})[ \t\x{00A0}\x{2009}\x{202F}]*:/u', $line, $match) !== 1) { | |
| 508 | return null; | |
| 509 | } | |
| 510 | ||
| 511 | return $match[1]; | |
| 512 | } | |
| 513 | ||
| 514 | private function isBlankLine(string $line): bool | |
| 515 | { | |
| 516 | if ($line === '') { | |
| 517 | return true; | |
| 518 | } | |
| 519 | ||
| 520 | return preg_match('/^[ \t\x{00A0}\x{2009}\x{202F}\x{FEFF}]*$/u', $line) === 1; | |
| 521 | } | |
| 522 | ||
| 523 | /** | |
| 524 | * A line with no colon and no date or "Dne"/"dňa" cannot be a bank closer or a forged header. | |
| 525 | */ | |
| 526 | private function couldBeBankLine(string $line): bool | |
| 527 | { | |
| 528 | if (str_contains($line, ':') || str_contains($line, 'Dne') || str_contains($line, 'dňa')) { | |
| 529 | return true; | |
| 530 | } | |
| 531 | ||
| 532 | return preg_match('/\d/u', $line) === 1; | |
| 533 | } | |
| 534 | ||
| 535 | /** | |
| 536 | * A closing line is the bank's own line, not a word the payer can type. | |
| 537 | * Bare `Zůstatek`, `zostatok`, or `banka:` do not close the field. | |
| 538 | * Tatra `uctovny zostatok` / `aktualny zostatok` / `disponibilny zostatok` do. | |
| 539 | */ | |
| 540 | private function isBankCloser(string $line, string $kind): bool | |
| 541 | { | |
| 542 | if ($this->isExactBalanceLine($line) || $this->isDigitSymbolLine($line) || $this->isExactAccountLine($line)) { | |
| 543 | return true; | |
| 544 | } | |
| 545 | ||
| 546 | if ($kind === 'tatra-message' || $kind === 'tatra-description') { | |
| 547 | return $this->isTatraBalanceLine($line); | |
| 548 | } | |
| 549 | ||
| 550 | return false; | |
| 551 | } | |
| 552 | ||
| 553 | private function isExactBalanceLine(string $line): bool | |
| 554 | { | |
| 555 | $ws = MailWhitespace::WS; | |
| 556 | $amount = '[+-]' . $ws . '(?:\d{1,3}(?:[ \t\x{00A0}\x{2009}\x{202F}]\d{3})*|\d+),\d{2}'; | |
| 557 | $cz = 'zůstatek na účtu po zaúčtování transakce:' . $ws . $amount . $ws . '[A-Z]{3}\.'; | |
| 558 | $sk = 'zostatok na účte po zaúčtovaní sumy platobnej operácie:' . $ws . $amount . $ws . '[A-Z]{3}\.?'; | |
| 559 | ||
| 560 | return preg_match('/^' . $ws . '(?:' . $cz . '|' . $sk . ')' . $ws . '$/ui', $line) === 1; | |
| 561 | } | |
| 562 | ||
| 563 | private function isDigitSymbolLine(string $line): bool | |
| 564 | { | |
| 565 | $ws = MailWhitespace::WS; | |
| 566 | ||
| 567 | return preg_match('/^' . $ws . '(?:variabilní symbol|konstantní symbol|specifický symbol)' . $ws . ':' . $ws . '\d+' . $ws . '$/ui', $line) === 1; | |
| 568 | } | |
| 569 | ||
| 570 | private function isExactAccountLine(string $line): bool | |
| 571 | { | |
| 572 | $ws = MailWhitespace::WS; | |
| 573 | $domestic = '\d{1,10}(?:-\d{1,10})?\/\d{4}'; | |
| 574 | $iban = '[A-Z]{2}\d{2}(?:[ \t\x{00A0}\x{2009}\x{202F}]?[A-Z0-9]{1,4})+'; | |
| 575 | $account = '(?:' . $iban . '|' . $domestic . ')'; | |
| 576 | $labels = 'účet protistrany(?:\/iban)?|protiúčet|z účtu'; | |
| 577 | ||
| 578 | return preg_match('/^' . $ws . '(?:' . $labels . ')' . $ws . ':' . $ws . $account . $ws . '$/ui', $line) === 1; | |
| 579 | } | |
| 580 | ||
| 581 | /** | |
| 582 | * Tatra prints the balance without a sign: `aktualny zostatok: 142,11 EUR`. | |
| 583 | * Deleting this rule leaves a later transaction sentence inside the payer message. | |
| 584 | */ | |
| 585 | private function isTatraBalanceLine(string $line): bool | |
| 586 | { | |
| 587 | $ws = MailWhitespace::WS; | |
| 588 | $amount = '[+-]?' . $ws . '(?:\d{1,3}(?:[ \t\x{00A0}\x{2009}\x{202F}]\d{3})*|\d+),\d{2}'; | |
| 589 | ||
| 590 | return preg_match('/^' . $ws . '(?:uctovny zostatok|aktualny zostatok|disponibilny zostatok)' . $ws . ':' . $ws . $amount . $ws . '[A-Za-z]{3}' . $ws . '$/u', $line) === 1; | |
| 591 | } | |
| 592 | } |