Passed
Push — master ( 89f24e...8c4dad )
by Sylvain
08:05
created

Bvr::isQrIban()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 6
nc 3
nop 1
dl 0
loc 13
ccs 7
cts 7
cp 1
crap 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Ecodev\Felix\Service;
6
7
use Exception;
8
use Money\Money;
9
10
/**
11
 * Class to generate BVR reference number and encoding lines.
12
 *
13
 * Typically usage would one of the following:
14
 *
15
 * ```php
16
 * <?php
17
 *
18
 * // Provided by your bank
19
 * $bankAccount = '800876';
20
 * $postAccount = '01-3456-0';
21
 *
22
 * // Your own custom ID to uniquely identify the payment
23
 * $myId = (string) $user->getId();
24
 *
25
 * $referenceNumberToCopyPasteInEBanking = Bvr::getReferenceNumber($bankAccount, $myId);
26
 *
27
 * // OR get encoding line
28
 * $amount = Money::CHF(1995);
29
 * $encodingLineToCopyPasteInEBanking = Bvr::getEncodingLine($bankAccount, $myId, $postAccount, $amount);
30
 * ```
31
 *
32
 * @see https://www.postfinance.ch/content/dam/pfch/doc/cust/download/inpayslip_isr_man_fr.pdf
33
 */
34
final class Bvr
35
{
36
    private const TABLE = [
37
        [0, 9, 4, 6, 8, 2, 7, 1, 3, 5],
38
        [9, 4, 6, 8, 2, 7, 1, 3, 5, 0],
39
        [4, 6, 8, 2, 7, 1, 3, 5, 0, 9],
40
        [6, 8, 2, 7, 1, 3, 5, 0, 9, 4],
41
        [8, 2, 7, 1, 3, 5, 0, 9, 4, 6],
42
        [2, 7, 1, 3, 5, 0, 9, 4, 6, 8],
43
        [7, 1, 3, 5, 0, 9, 4, 6, 8, 2],
44
        [1, 3, 5, 0, 9, 4, 6, 8, 2, 7],
45
        [3, 5, 0, 9, 4, 6, 8, 2, 7, 1],
46
        [5, 0, 9, 4, 6, 8, 2, 7, 1, 3],
47
    ];
48
49
    /**
50
     * Get the reference number, including the verification digit
51
     */
52 12
    public static function getReferenceNumber(string $bankAccount, string $customId): string
53
    {
54 12
        if (!preg_match('~^\d{0,20}$~', $customId)) {
55 4
            throw new Exception('Invalid custom ID. It must be 20 or less digits, but got: `' . $customId . '`');
56
        }
57
58 8
        if (!preg_match('~^\d{6}$~', $bankAccount)) {
59 1
            throw new Exception('Invalid bank account. It must be exactly 6 digits, but got: `' . $bankAccount . '`');
60
        }
61 7
        $value = $bankAccount . self::pad($customId, 20);
62
63 7
        return $value . self::modulo10($value);
64
    }
65
66
    /**
67
     * Extract the custom ID as string from a valid reference number
68
     */
69 5
    public static function extractCustomId(string $referenceNumber): string
70
    {
71 5
        if (!preg_match('~^\d{27}$~', $referenceNumber)) {
72 1
            throw new Exception('Invalid reference number. It must be exactly 27 digits, but got: `' . $referenceNumber . '`');
73
        }
74 4
        $value = mb_substr($referenceNumber, 0, 26);
75 4
        $expectedVerificationDigit = (int) mb_substr($referenceNumber, 26, 27);
76 4
        $actualVerificationDigit = self::modulo10($value);
77 4
        if ($expectedVerificationDigit !== $actualVerificationDigit) {
78 1
            throw new Exception('Invalid reference number. The verification digit does not match. Expected `' . $expectedVerificationDigit . '`, but got `' . $actualVerificationDigit . '`');
79
        }
80
81 3
        return mb_substr($referenceNumber, 6, 20);
82
    }
83
84
    /**
85
     * Check if an IBAN is actually a valid Swiss QR-IBAN
86
     */
87 4
    public static function isQrIban(string $iban): bool
88
    {
89 4
        if (!preg_match('/^CH[0-9]{2}([0-9]{5})[0-9A-Z]{12}$/', $iban, $m)) {
90 2
            return false;
91
        }
92
93 2
        $bankClearing = (int) $m[1];
94
95 2
        if ($bankClearing >= 30000 && $bankClearing <= 31199) {
96 1
            return true;
97
        }
98
99 1
        return false;
100
    }
101
102
    /**
103
     * Get the full encoding line
104
     *
105
     * @deprecated since 2.3.2 as legacy BVR is being replaced by QR bill
106
     */
107 8
    public static function getEncodingLine(string $bankAccount, string $customId, string $postAccount, ?Money $amount = null): string
108
    {
109 8
        $type = self::getType($amount);
110 7
        $referenceNumber = self::getReferenceNumber($bankAccount, $customId);
111 5
        $formattedPostAccount = self::getPostAccount($postAccount);
112
113
        $result =
114 3
            $type . '>'
115 3
            . $referenceNumber . '+ '
116 3
            . $formattedPostAccount . '>';
117
118 3
        return $result;
119
    }
120
121 7
    private static function pad(string $string, int $length): string
122
    {
123 7
        return str_pad($string, $length, '0', STR_PAD_LEFT);
124
    }
125
126 29
    public static function modulo10(string $number): int
127
    {
128 29
        $report = 0;
129
130 29
        if ($number === '') {
131 1
            return $report;
132
        }
133
134 28
        $digits = mb_str_split($number);
135 28
        foreach ($digits as $value) {
136 28
            $report = self::TABLE[$report][(int) $value];
137
        }
138
139 28
        return (10 - $report) % 10;
140
    }
141
142 5
    private static function getPostAccount(string $postAccount): string
143
    {
144 5
        if (!preg_match('~^(\d+)-(\d+)-(\d)$~', $postAccount, $m)) {
145 1
            throw new Exception('Invalid post account number, got `' . $postAccount . '`');
146
        }
147
148 4
        $participantNumber = self::pad($m[1], 2) . self::pad($m[2], 6) . $m[3];
149
150 4
        if (mb_strlen($participantNumber) !== 9) {
151 1
            throw new Exception('The post account number is too long, got `' . $postAccount . '`');
152
        }
153
154 3
        return $participantNumber;
155
    }
156
157
    /**
158
     * Get type of document and amount
159
     */
160 8
    private static function getType(?Money $amount): string
161
    {
162 8
        if ($amount === null) {
163 6
            $type = '04';
164 2
        } elseif ($amount->isNegative()) {
165 1
            throw new Exception('Invalid amount. Must be positive, but got: `' . $amount->getAmount() . '`');
166
        } else {
167 1
            $type = '01' . self::pad($amount->getAmount(), 10);
168
        }
169
170 7
        return $type . self::modulo10($type);
171
    }
172
}
173