Lines 100.00% 63 / 63
Functions and Methods 100.00% 6 / 6
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
BankDate 100.00% 63 / 63 100.00% 6 / 6 38 100.00% 1 / 1
 bratislava 100.00% 1 / 1 100.00% 1 / 1 1
 utc 100.00% 1 / 1 100.00% 1 / 1 1
 parseFirst 100.00% 5 / 5 100.00% 1 / 1 3
 parseEnglish 100.00% 15 / 15 100.00% 1 / 1 3
 parse 100.00% 27 / 27 100.00% 1 / 1 16
 englishMonth 100.00% 14 / 14 100.00% 1 / 1 14
1<?php
2
3declare(strict_types=1);
4
5namespace Tomaj\BankMailsParser\Parser;
6
7use DateTimeImmutable;
8use DateTimeZone;
9
10/**
11 * Parses bank email dates without inheriting date.timezone and without rolling invalid dates forward.
12 *
13 * @internal Not part of the supported public API. Call it from a parser, and expect the formats to change.
14 */
15final class BankDate
16{
17    public const string ZONE = 'Europe/Bratislava';
18
19    public static function bratislava(): DateTimeZone
20    {
21        return new DateTimeZone(self::ZONE);
22    }
23
24    public static function utc(): DateTimeZone
25    {
26        return new DateTimeZone('UTC');
27    }
28
29    /**
30     * @param list<string> $formats
31     */
32    public static function parseFirst(string $value, array $formats, DateTimeZone $zone): ?DateTimeImmutable
33    {
34        foreach ($formats as $format) {
35            $parsed = self::parse($value, $format, $zone);
36            if ($parsed !== null) {
37                return $parsed;
38            }
39        }
40
41        return null;
42    }
43
44    /**
45     * English month names, the way 4.x `strtotime()` read `16 January 2015 12:51`.
46     * A numeric date stays on {@see parse()} so `31.2.2015` does not roll forward.
47     */
48    public static function parseEnglish(string $value, DateTimeZone $zone): ?DateTimeImmutable
49    {
50        if (preg_match('/^(\d{1,2})[ \t]+([A-Za-z]+)[ \t]+(\d{4})(?:[ \t]+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/', trim($value), $match) !== 1) {
51            return null;
52        }
53
54        $month = self::englishMonth($match[2]);
55        if ($month === null) {
56            return null;
57        }
58
59        $clock = sprintf(
60            '%04d-%02d-%02d %02d:%02d:%02d',
61            (int) $match[3],
62            $month,
63            (int) $match[1],
64            (int) ($match[4] ?? 0),
65            (int) ($match[5] ?? 0),
66            (int) ($match[6] ?? 0),
67        );
68
69        return self::parse($clock, '!Y-m-d H:i:s', $zone);
70    }
71
72    public static function parse(string $value, string $format, DateTimeZone $zone): ?DateTimeImmutable
73    {
74        $value = trim($value);
75        $nextDay = false;
76        if ((str_contains($format, 'G:i') || str_contains($format, 'H:i'))
77            && preg_match('/24:00(?::00)?$/', $value) === 1
78        ) {
79            $rewritten = preg_replace('/24:00(:00)?$/', '00:00$1', $value, 1);
80            if (is_string($rewritten)) {
81                $value = $rewritten;
82                $nextDay = true;
83            }
84        }
85
86        // Parse the wall clock in UTC first. Europe/Bratislava createFromFormat rolls a
87        // spring-forward gap (29.3.2015 02:30 â†’ 03:30) without setting getLastErrors().
88        $wall = DateTimeImmutable::createFromFormat($format, $value, self::utc());
89        if ($wall === false) {
90            return null;
91        }
92
93        $errors = DateTimeImmutable::getLastErrors();
94        if (is_array($errors) && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) {
95            return null;
96        }
97
98        $year = (int) $wall->format('Y');
99        if ($year < 1000 || $year > 9999) {
100            return null;
101        }
102
103        $clock = $wall->format('Y-m-d H:i:s');
104        $parsed = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $clock, $zone);
105        if ($parsed === false || $parsed->format('Y-m-d H:i:s') !== $clock) {
106            return null;
107        }
108
109        if ($nextDay) {
110            $parsed = $parsed->modify('+1 day');
111            $year = (int) $parsed->format('Y');
112            if ($year < 1000 || $year > 9999) {
113                return null;
114            }
115        }
116
117        return $parsed;
118    }
119
120    private static function englishMonth(string $name): ?int
121    {
122        return match (strtolower($name)) {
123            'january', 'jan' => 1,
124            'february', 'feb' => 2,
125            'march', 'mar' => 3,
126            'april', 'apr' => 4,
127            'may' => 5,
128            'june', 'jun' => 6,
129            'july', 'jul' => 7,
130            'august', 'aug' => 8,
131            'september', 'sep', 'sept' => 9,
132            'october', 'oct' => 10,
133            'november', 'nov' => 11,
134            'december', 'dec' => 12,
135            default => null,
136        };
137    }
138}