Lines
97.27%
250 / 257
Methods
86.84%
33 / 38
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| bankDomains | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| supports | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 6 | ||
| parse | 100.00% | 55 / 55 | 100.00% | 1 / 1 | 15 | ||
| [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 | ||
| 13 | final readonly class VubMailParser implements ParserInterface | |
| 14 | { | |
| 15 | use ParsesBankMail; | |
| 16 | ||
| 17 | /** | |
| 18 | * @return list<string> | |
| 19 | */ | |
| 20 | #[\Override] | |
| 21 | public function bankDomains(): array | |
| 22 | { | |
| 23 | return ['vub.sk']; | |
| 24 | } | |
| 25 | ||
| 26 | #[\Override] | |
| 27 | public function supports(string $content, ?string $sender = null, ?string $subject = null): bool | |
| 28 | { | |
| 29 | return $this->supportsQuietly(function () use ($content): bool { | |
| 30 | $content = $this->withoutByteOrderMark($content); | |
| 31 | $hasDate = $this->matched($this->field('D(?:á|a)?tum', ':'), $content); | |
| 32 | $hasSource = $this->matched($this->field('Z[ \t\x{00A0}\x{2009}\x{202F}]*(?:(?:ú|u)?čtu|tu)', ':'), $content); | |
| 33 | $hasDestination = $this->matched($this->field('Na[ \t\x{00A0}\x{2009}\x{202F}]+(?:účet|et)', ':'), $content); | |
| 34 | $hasStatus = $this->matched($this->field('(?:Stav|SIGN)', ':'), $content); | |
| 35 | $hasAmount = $this->matched($this->field('Suma', ':'), $content); | |
| 36 | $hasVs = $this->matched($this->field('VS', ':'), $content); | |
| 37 | ||
| 38 | return $hasDate && $hasSource && $hasDestination && $hasStatus && $hasAmount && $hasVs; | |
| 39 | }); | |
| 40 | } | |
| 41 | ||
| 42 | public function parse(string $content): MailContent | |
| 43 | { | |
| 44 | $content = $this->requireUniformLineBreaks($content); | |
| 45 | $signals = 0; | |
| 46 | ||
| 47 | $transactionDate = null; | |
| 48 | if ($this->matched($this->field('D(?:á|a)?tum:', '(.*)$'), $content, $result)) { | |
| 49 | ++$signals; | |
| 50 | $transactionDate = BankDate::parseFirst(mb_trim($this->group($result, 1), " \t\n\r\0\x0B\u{00A0}\u{2009}\u{202F}"), ['!j.n.Y', '!Y-m-d', '!j.n.Y G:i'], BankDate::bratislava()); | |
| 51 | } | |
| 52 | ||
| 53 | $ownAccountNumber = null; | |
| 54 | if ($this->matched($this->field('Na[ \t\x{00A0}\x{2009}\x{202F}]+(?:účet|et):', '([A-Z0-9 \x{00A0}\x{2009}\x{202F}\t]*)'), $content, $result)) { | |
| 55 | $account = $this->normalizeAccount($this->group($result, 1)); | |
| 56 | if ($account !== '') { | |
| 57 | ++$signals; | |
| 58 | $ownAccountNumber = $account; | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | $accountNumber = null; | |
| 63 | if ($this->matched($this->field('Z[ \t\x{00A0}\x{2009}\x{202F}]*(?:(?:ú|u)?čtu|tu):', '([A-Z0-9 \x{00A0}\x{2009}\x{202F}\t]*)'), $content, $result)) { | |
| 64 | $account = $this->normalizeAccount($this->group($result, 1)); | |
| 65 | if ($account !== '') { | |
| 66 | ++$signals; | |
| 67 | $accountNumber = $account; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | $money = null; | |
| 72 | if ($this->matched($this->field('Suma:', '(.*)$'), $content, $result)) { | |
| 73 | ++$signals; | |
| 74 | $raw = trim(str_replace(["\u{00A0}", "\u{2009}", "\u{202F}", "\t"], ' ', $this->group($result, 1))); | |
| 75 | if ($raw !== '' && preg_match('/^(-?\d(?:[\d \x{00A0}\x{2009}\x{202F}\t.,]*\d)?)(?:[ \t]*([A-Z]{3}))?$/u', $raw, $amount) === 1) { | |
| 76 | $currency = $amount[2] ?? 'EUR'; | |
| 77 | $money = $this->moneyOrNull($amount[1], $currency, ','); | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | $vs = null; | |
| 82 | if ($this->matched($this->field('VS:', '([0-9]+)'), $content, $result)) { | |
| 83 | ++$signals; | |
| 84 | $vs = $this->paymentSymbol($this->group($result, 1)); | |
| 85 | } | |
| 86 | ||
| 87 | $ks = null; | |
| 88 | if ($this->matched($this->field('KS:', '([0-9]+)'), $content, $result)) { | |
| 89 | ++$signals; | |
| 90 | $ks = $this->paymentSymbol($this->group($result, 1)); | |
| 91 | } | |
| 92 | ||
| 93 | $ss = null; | |
| 94 | if ($this->matched($this->field('(?:ŠS|SS|S):', '([0-9]+)'), $content, $result)) { | |
| 95 | ++$signals; | |
| 96 | $ss = $this->paymentSymbol($this->group($result, 1)); | |
| 97 | } | |
| 98 | ||
| 99 | $sign = null; | |
| 100 | if ($this->matched($this->field('SIGN:', '(.*)$'), $content, $result)) { | |
| 101 | ++$signals; | |
| 102 | $value = mb_trim($this->group($result, 1), " \t\n\r\0\x0B\u{00A0}\u{2009}\u{202F}"); | |
| 103 | if ($value !== '') { | |
| 104 | $sign = $value; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | if ($signals < 2) { | |
| 109 | throw InvalidEmailFormatException::malformedContent(self::class, 'No VÚB fields found'); | |
| 110 | } | |
| 111 | ||
| 112 | return new MailContent( | |
| 113 | money: $money, | |
| 114 | transactionDate: $transactionDate, | |
| 115 | accountNumber: $accountNumber, | |
| 116 | ownAccountNumber: $ownAccountNumber, | |
| 117 | vs: $vs, | |
| 118 | ss: $ss, | |
| 119 | ks: $ks, | |
| 120 | sign: $sign, | |
| 121 | ); | |
| 122 | } | |
| 123 | } |
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 | } |