Lines 94.01% 110 / 117
Functions and Methods 50.00% 4 / 8
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
TatraBankaSimpleMailParser 94.01% 110 / 117 50.00% 4 / 8 64.88 0.00% 0 / 1
 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
1<?php
2
3declare(strict_types=1);
4
5namespace Tomaj\BankMailsParser\Parser\TatraBanka;
6
7use DateTimeImmutable;
8use Tomaj\BankMailsParser\Exception\InvalidEmailFormatException;
9use Tomaj\BankMailsParser\MailContent;
10use Tomaj\BankMailsParser\Parser\BankDate;
11use Tomaj\BankMailsParser\Parser\ParserInterface;
12use Tomaj\BankMailsParser\Parser\ParsesBankMail;
13use Tomaj\BankMailsParser\ValueObject\Currency;
14
15final 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}