substr_count($rawData, '@@-') ? "\r\n" : '@@'; $cleanedRawData = preg_replace('#' . $divider . '([^:])#ms', '$1', $rawData); $booked = true; $result = []; $days = explode($divider . '-', $cleanedRawData); $soaDate = null; foreach ($days as &$day) { $day = explode($divider . ':', $day); for ($i = 0, $cnt = count($day); $i < $cnt; ++$i) { if (preg_match("/\+\@[0-9]+\@$/", trim($day[$i]))) { $booked = false; } // handle start balance // 60F:C160401EUR1234,56 if (preg_match('/^60(F|M):/', $day[$i])) { // remove 60(F|M): for better parsing $day[$i] = substr($day[$i], 4); $soaDate = $this->getDate(substr($day[$i], 1, 6)); // if this statement date ist first seen set start_balance // Note: all further transactions in different statements with the same soaDate will be appended // there will be no new statement done for them. With bigger code changes this could be changed. // For now this is shortcutted like this for fixing https://github.com/nemiah/phpFinTS/issues/367 if (!isset($result[$soaDate])) { $result[$soaDate] = ['start_balance' => []]; $cdMark = substr($day[$i], 0, 1); if ($cdMark === 'C') { $result[$soaDate]['start_balance']['credit_debit'] = static::CD_CREDIT; } elseif ($cdMark === 'D') { $result[$soaDate]['start_balance']['credit_debit'] = static::CD_DEBIT; } $amount = str_replace(',', '.', substr($day[$i], 10)); $result[$soaDate]['start_balance']['amount'] = $amount; } } elseif ( // found transaction // trx:61:1603310331DR637,39N033NONREF str_starts_with($day[$i], '61:') && isset($day[$i + 1]) && str_starts_with($day[$i + 1], '86:') ) { $transaction = substr($day[$i], 3); $description = substr($day[$i + 1], 3); if (!isset($result[$soaDate]['transactions'])) { $result[$soaDate]['transactions'] = []; } // short form for better handling $trx = &$result[$soaDate]['transactions']; preg_match('/^\d{6}(\d{4})?(C|D|RC|RD)([A-Z]{1})?([^N]+)N/', $transaction, $trxMatch); if ($trxMatch[2] === 'C' || $trxMatch[2] === 'RC') { $trx[count($trx)]['credit_debit'] = static::CD_CREDIT; } elseif ($trxMatch[2] === 'D' || $trxMatch[2] === 'RD') { $trx[count($trx)]['credit_debit'] = static::CD_DEBIT; } else { throw new MT940Exception('cd mark not found in: ' . $transaction); } $trx[count($trx) - 1]['is_storno'] = ($trxMatch[2] === 'RC' or $trxMatch[2] === 'RD'); $amount = $trxMatch[4]; $amount = str_replace(',', '.', $amount); $trx[count($trx) - 1]['amount'] = $amount; // :61:1605110509D198,02NMSCNONREF // 16 = year // 0511 = valuta date // 0509 = booking date $year = substr($transaction, 0, 2); $valutaDate = $this->getDate($year . substr($transaction, 2, 4)); $bookingDatePart = substr($transaction, 6, 4); if (preg_match('/^\d{4}$/', $bookingDatePart) === 1) { // try to guess the correct year of the booking date $valutaDateTime = new \DateTime($valutaDate); $bookingDateTime = new \DateTime($this->getDate($year . $bookingDatePart)); // the booking date can be before or after the valuata date // and one of them can be in another year for example 12-31 and 01-01 $diff = $valutaDateTime->diff($bookingDateTime); // if diff is more than half a year if ($diff->days > 182) { // and positive if ($diff->invert === 0) { // its in the last year --$year; } // and negative else { // its in the next year ++$year; } } $bookingDate = $this->getDate($year . $bookingDatePart); } else { // if booking date not set in :61, then we have to take it from :60F $bookingDate = $soaDate; } $trx[count($trx) - 1]['booking_date'] = $bookingDate; $trx[count($trx) - 1]['valuta_date'] = $valutaDate; $trx[count($trx) - 1]['booked'] = $booked; $trx[count($trx) - 1]['description'] = $this->parseDescription($description, $trx[count($trx) - 1]); } elseif ( preg_match('/^62F:/', $day[$i]) // handle end balance ) { // remove 62F: for better parsing $day[$i] = substr($day[$i], 4); $soaDate = $this->getDate(substr($day[$i], 1, 6)); if (isset($result[$soaDate])) { // $result[$soaDate] = ['end_balance' => []]; $amount = str_replace(',', '.', substr($day[$i], 10, -1)); $cdMark = substr($day[$i], 0, 1); if ($cdMark == 'C') { $result[$soaDate]['end_balance']['credit_debit'] = static::CD_CREDIT; } elseif ($cdMark == 'D') { $result[$soaDate]['end_balance']['credit_debit'] = static::CD_DEBIT; $amount *= -1; } $result[$soaDate]['end_balance']['amount'] = $amount; } } } } return $result; } protected function parseDescription($descr, $transaction): array { // Geschäftsvorfall-Code $gvc = substr($descr, 0, 3); $prepared = []; $result = []; // prefill with empty values for ($i = 0; $i <= 63; ++$i) { $prepared[$i] = null; } $descr = str_replace('? ', '?', $descr); preg_match_all('/\?(\d{2})([^\?]+)/', $descr, $matches, PREG_SET_ORDER); $descriptionLines = []; $description1 = ''; // Legacy, could be removed. $description2 = ''; // Legacy, could be removed. foreach ($matches as $m) { $index = (int) $m[1]; if ((20 <= $index && $index <= 29) || (60 <= $index && $index <= 63)) { if (20 <= $index && $index <= 29) { $description1 .= $m[2]; } else { $description2 .= $m[2]; } $descriptionLines[] = $m[2]; } $prepared[$index] = $m[2]; } $description = $this->extractStructuredDataFromRemittanceLines($descriptionLines, $gvc, $prepared, $transaction); $result['booking_code'] = $gvc; $result['booking_text'] = trim($prepared[0] ?? ''); $result['description'] = $description; $result['primanoten_nr'] = trim($prepared[10] ?? ''); $result['description_1'] = trim($description1); $result['bank_code'] = trim($prepared[30] ?? ''); $result['account_number'] = trim($prepared[31] ?? ''); $result['name'] = trim(($prepared[32] ?? '') . ($prepared[33] ?? '')); $result['text_key_addition'] = trim($prepared[34] ?? ''); $result['description_2'] = $description2; $result['desc_lines'] = $descriptionLines; return $result; } /** * @param string[] $descriptionLines that contain the remittance information * @param string $gvc Geschätsvorfallcode; Out-Parameter, might be changed from information in remittance info * @param string[] $rawLines All the lines in the Multi-Purpose-Field 86; Out-Parameter, might be changed from information in remittance info */ protected function extractStructuredDataFromRemittanceLines($descriptionLines, string &$gvc, array &$rawLines, array $transaction): array { $description = []; if (empty($descriptionLines) || strlen($descriptionLines[0]) < 5 || $descriptionLines[0][4] !== '+') { $description['SVWZ'] = implode('', $descriptionLines); } else { $lastType = null; foreach ($descriptionLines as $line) { if (strlen($line) >= 5 && $line[4] === '+') { if ($lastType != null) { $description[$lastType] = trim($description[$lastType]); } $lastType = substr($line, 0, 4); $description[$lastType] = substr($line, 5); } else { $description[$lastType] .= $line; } if (strlen($line) < 27) { // Usually, lines are 27 characters long. In case characters are missing, then it's either the end // of the current type or spaces have been trimmed from the end. We want to collapse multiple spaces // into one and we don't want to leave trailing spaces behind. So add a single space here to make up // for possibly missing spaces, and if it's the end of the type, it will be trimmed off later. $description[$lastType] .= ' '; } } $description[$lastType] = trim($description[$lastType]); } return $description; } protected function getDate(string $val): string { $val = '20' . $val; preg_match('/(\d{4})(\d{2})(\d{2})/', $val, $m); return $m[1] . '-' . $m[2] . '-' . $m[3]; } }