Lines
95.45%
294 / 308
Methods
79.06%
34 / 43
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| bankDomains | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| supports | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 3 | ||
| parse | 94.54% | 52 / 55 | 0.00% | 0 / 1 | 23.09 | ||
| parseTimestamp | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 4 | ||
| firstNotificationBlock | 92.00% | 23 / 25 | 0.00% | 0 / 1 | 15.12 | ||
| cardPayPairs | 88.88% | 8 / 9 | 0.00% | 0 / 1 | 7.07 | ||
| mixesProse | 85.71% | 6 / 7 | 0.00% | 0 / 1 | 5.07 | ||
| cardPayBlock | 100.00% | 3 / 3 | 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 TatraBankaSimpleMailParser implements ParserInterface | |
| 16 | { | |
| 17 | use ParsesBankMail; | |
| 18 | ||
| 19 | private const array FIELD_MAP = [ | |
| 20 | 'VS' => 'vs', | |
| 21 | 'RES' => 'res', | |
| 22 | 'AC' => 'ac', | |
| 23 | 'SIGN' => 'sign', | |
| 24 | 'TRES' => 'tres', | |
| 25 | 'CID' => 'cid', | |
| 26 | 'AMT' => 'amount', | |
| 27 | 'CURR' => 'currency', | |
| 28 | 'CC' => 'cc', | |
| 29 | 'TID' => 'tid', | |
| 30 | 'TIMESTAMP' => 'transactionDate', | |
| 31 | 'TXN' => 'txn', | |
| 32 | 'RC' => 'rc', | |
| 33 | 'HMAC' => 'sign', | |
| 34 | ]; | |
| 35 | ||
| 36 | /** | |
| 37 | * @return list<string> | |
| 38 | */ | |
| 39 | #[\Override] | |
| 40 | public function bankDomains(): array | |
| 41 | { | |
| 42 | return ['tatrabanka.sk']; | |
| 43 | } | |
| 44 | ||
| 45 | #[\Override] | |
| 46 | public function supports(string $content, ?string $sender = null, ?string $subject = null): bool | |
| 47 | { | |
| 48 | return $this->supportsQuietly(function () use ($content): bool { | |
| 49 | $block = $this->firstNotificationBlock($this->withoutFreeText($this->scrubUtf8($this->withoutByteOrderMark($content)))); | |
| 50 | if ($block === null) { | |
| 51 | return false; | |
| 52 | } | |
| 53 | ||
| 54 | $keys = []; | |
| 55 | foreach ($block as [$key]) { | |
| 56 | $keys[$key] = true; | |
| 57 | } | |
| 58 | ||
| 59 | return self::cardPayBlock($keys); | |
| 60 | }); | |
| 61 | } | |
| 62 | ||
| 63 | public function parse(string $content): MailContent | |
| 64 | { | |
| 65 | $block = $this->firstNotificationBlock($this->withoutFreeText($this->scrubUtf8($this->requireUniformLineBreaks($content)))); | |
| 66 | if ($block === null) { | |
| 67 | throw InvalidEmailFormatException::malformedContent(self::class, 'Empty content'); | |
| 68 | } | |
| 69 | ||
| 70 | $data = []; | |
| 71 | $seen = []; | |
| 72 | $sign = null; | |
| 73 | $hmac = null; | |
| 74 | foreach ($block as [$key, $value]) { | |
| 75 | $value = mb_trim($value); | |
| 76 | if ($key === 'SIGN') { | |
| 77 | if ($sign !== null && $sign !== $value) { | |
| 78 | throw InvalidEmailFormatException::malformedContent(self::class, 'Duplicate CardPay field sign'); | |
| 79 | } | |
| 80 | $sign = $value; | |
| 81 | ||
| 82 | continue; | |
| 83 | } | |
| 84 | if ($key === 'HMAC') { | |
| 85 | if ($hmac !== null && $hmac !== $value) { | |
| 86 | throw InvalidEmailFormatException::malformedContent(self::class, 'Duplicate CardPay field sign'); | |
| 87 | } | |
| 88 | $hmac = $value; | |
| 89 | ||
| 90 | continue; | |
| 91 | } | |
| 92 | if (!isset(self::FIELD_MAP[$key])) { | |
| 93 | continue; | |
| 94 | } | |
| 95 | ||
| 96 | $property = self::FIELD_MAP[$key]; | |
| 97 | $data[$property] = $value; | |
| 98 | $seen[$property][] = $data[$property]; | |
| 99 | } | |
| 100 | if ($hmac !== null || $sign !== null) { | |
| 101 | $data['sign'] = $hmac ?? $sign; | |
| 102 | } | |
| 103 | ||
| 104 | foreach ($seen as $property => $values) { | |
| 105 | if (count(array_unique($values)) > 1) { | |
| 106 | throw InvalidEmailFormatException::malformedContent(self::class, 'Duplicate CardPay field ' . $property); | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | if (!isset($data['res']) && !isset($data['tres'])) { | |
| 111 | throw InvalidEmailFormatException::missingRequiredField(self::class, 'RES'); | |
| 112 | } | |
| 113 | ||
| 114 | $money = null; | |
| 115 | if (isset($data['amount']) && $data['amount'] !== '') { | |
| 116 | $currency = isset($data['currency']) && $data['currency'] !== '' ? $data['currency'] : 'EUR'; | |
| 117 | if (Currency::tryFromCode($currency) !== null) { | |
| 118 | $money = $this->money($data['amount'], $currency, '.'); | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | $tres = $data['tres'] ?? null; | |
| 123 | $res = $tres ?? ($data['res'] ?? null); | |
| 124 | ||
| 125 | $transactionDate = null; | |
| 126 | if (isset($data['transactionDate']) && $data['transactionDate'] !== '') { | |
| 127 | $transactionDate = self::parseTimestamp($data['transactionDate']); | |
| 128 | } | |
| 129 | ||
| 130 | return new MailContent( | |
| 131 | money: $money, | |
| 132 | transactionDate: $transactionDate, | |
| 133 | vs: $data['vs'] ?? null, | |
| 134 | cid: $data['cid'] ?? null, | |
| 135 | sign: $data['sign'] ?? null, | |
| 136 | res: $res, | |
| 137 | ac: $data['ac'] ?? null, | |
| 138 | cc: $data['cc'] ?? null, | |
| 139 | tid: $data['tid'] ?? null, | |
| 140 | txn: $data['txn'] ?? null, | |
| 141 | rc: $data['rc'] ?? null, | |
| 142 | tres: $tres, | |
| 143 | ); | |
| 144 | } | |
| 145 | ||
| 146 | private static function parseTimestamp(string $value): DateTimeImmutable | |
| 147 | { | |
| 148 | if (preg_match('/^\d{14}$/', $value) === 1) { | |
| 149 | $parsed = BankDate::parse($value, '!dmYHis', BankDate::utc()); | |
| 150 | if ($parsed === null) { | |
| 151 | throw InvalidEmailFormatException::malformedContent(self::class, 'TIMESTAMP is not a valid date'); | |
| 152 | } | |
| 153 | ||
| 154 | return $parsed; | |
| 155 | } | |
| 156 | ||
| 157 | if (preg_match('/^\d{10}$/', $value) === 1) { | |
| 158 | return new DateTimeImmutable('@' . $value); | |
| 159 | } | |
| 160 | ||
| 161 | throw InvalidEmailFormatException::malformedContent(self::class, 'TIMESTAMP is not a valid date'); | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * First contiguous KEY=value region that carries RES or TRES. Quoted lines are ignored. | |
| 166 | * | |
| 167 | * @return list<array{0: string, 1: string}>|null | |
| 168 | */ | |
| 169 | private function firstNotificationBlock(string $content): ?array | |
| 170 | { | |
| 171 | $lines = preg_split('/\R/u', $content); | |
| 172 | if ($lines === false) { | |
| 173 | return null; | |
| 174 | } | |
| 175 | ||
| 176 | /** @var list<array{0: string, 1: string}> $pairs */ | |
| 177 | $pairs = []; | |
| 178 | $hasResult = false; | |
| 179 | foreach ($lines as $line) { | |
| 180 | $bare = preg_replace('/^\x{FEFF}/u', '', $line) ?? $line; | |
| 181 | if (preg_match('/^[ \t]*>/', $bare) === 1 || trim($bare) === '') { | |
| 182 | continue; | |
| 183 | } | |
| 184 | ||
| 185 | if ($pairs !== [] && self::mixesProse($bare)) { | |
| 186 | return $pairs; | |
| 187 | } | |
| 188 | ||
| 189 | $linePairs = self::cardPayPairs($bare); | |
| 190 | if ($linePairs === null) { | |
| 191 | if ($hasResult && $pairs !== []) { | |
| 192 | return $pairs; | |
| 193 | } | |
| 194 | $pairs = []; | |
| 195 | $hasResult = false; | |
| 196 | ||
| 197 | continue; | |
| 198 | } | |
| 199 | ||
| 200 | foreach ($linePairs as $pair) { | |
| 201 | if ($pair[0] === 'RES' || $pair[0] === 'TRES') { | |
| 202 | $hasResult = true; | |
| 203 | } | |
| 204 | $pairs[] = $pair; | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | if ($hasResult && $pairs !== []) { | |
| 209 | return $pairs; | |
| 210 | } | |
| 211 | ||
| 212 | return null; | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * @return list<array{0: string, 1: string}>|null | |
| 217 | */ | |
| 218 | private static function cardPayPairs(string $line): ?array | |
| 219 | { | |
| 220 | $parts = preg_split('/\s+/u', trim($line)); | |
| 221 | if ($parts === false || $parts === [] || $parts === ['']) { | |
| 222 | return null; | |
| 223 | } | |
| 224 | ||
| 225 | $pairs = []; | |
| 226 | foreach ($parts as $part) { | |
| 227 | if (preg_match('/^([A-Z][A-Z0-9_]*)=(.*)$/', $part, $match) !== 1) { | |
| 228 | continue; | |
| 229 | } | |
| 230 | $pairs[] = [$match[1], $match[2]]; | |
| 231 | } | |
| 232 | ||
| 233 | return $pairs === [] ? null : $pairs; | |
| 234 | } | |
| 235 | ||
| 236 | private static function mixesProse(string $line): bool | |
| 237 | { | |
| 238 | $parts = preg_split('/\s+/u', trim($line)); | |
| 239 | if ($parts === false) { | |
| 240 | return false; | |
| 241 | } | |
| 242 | foreach ($parts as $part) { | |
| 243 | if ($part !== '' && preg_match('/^[A-Z][A-Z0-9_]*=/', $part) !== 1) { | |
| 244 | return true; | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | return false; | |
| 249 | } | |
| 250 | ||
| 251 | /** | |
| 252 | * @param array<string, true> $keys | |
| 253 | */ | |
| 254 | private static function cardPayBlock(array $keys): bool | |
| 255 | { | |
| 256 | $hasResult = isset($keys['RES']) || isset($keys['TRES']); | |
| 257 | $hasAnchor = isset($keys['AMT']) || isset($keys['VS']) || isset($keys['HMAC']) || isset($keys['SIGN']); | |
| 258 | ||
| 259 | return $hasResult && $hasAnchor; | |
| 260 | } | |
| 261 | } |
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 | } |