import subprocess from datetime import datetime import re # For not-greedy `?`, see Source: [the Stack Overflow answer 766377](https://stackoverflow.com/a/766377) FIRST_LINE_OF_PAYMENT_REGEX = re.compile('\\ +(\\d{2}\\.\\d{2})\\ +([A-Z\\d /.()*\\-,]+?)\\ +(\\d{2}\\.\\d{2})\\ +([\\d ]+,\\d{2})') END_PAGE_AFTER_THE_FIRST_ONE_REGEX = re.compile(' +RELEVE ((DE (COMPTE (CHEQUES|D\'EPARGNE LOGEMENT|LEP))|LIVRET (A|JEUNE))|LIVRET DEV. DURABLE ET SOLIDAIRE) +P\\. \\d+/\\d+') SOLDE_CREDITEUR_AU_REGEX = re.compile('\\ +SOLDE CREDITEUR AU (\\d{2}\\.\\d{2}\\.\\d{4})\\ +([\\d ]+,\\d{2})') TOTAL_DES_OPERATIONS_REGEX = re.compile('\\ +TOTAL\\ DES\\ OPERATIONS\\ +([\\d ]+,\\d{2})\\ +([\\d ]+,\\d{2})') TOTAL_DES_OPERATIONS_CREDIT_ONLY_REGEX = re.compile('\\ +TOTAL\\ DES\\ OPERATIONS\\ +([\\d ]+,\\d{2})') COLUMNS_HEADER = re.compile(' +Date +Nature des opérations +Valeur +Débit +Crédit') def execute(command): return subprocess.check_output(command).decode('utf-8') def getTextFromPdf(pdfPath): return execute(['pdftotext', '-layout', pdfPath, '-']) def getDatetimeFromFileName(aDatetimeStr): aDatetime = datetime(int(aDatetimeStr[:4]), int(aDatetimeStr[4:6]), 1) return aDatetime def getMonthIndexSinceEpoch(aDatetime): return aDatetime.year * 12 + aDatetime.month def getMonthNameFromMonthIndex(monthIndex): return datetime((monthIndex - 1) // 12, 1 + (monthIndex - 1) % 12, 1).strftime('%b %Y') def toFloat(group): return float(group.replace(',', '.').replace(' ', '')) def getDateFollowing(date, initialDate): date = datetime.strptime(date, '%d.%m').replace(year = initialDate.year) # To support new year. if date < initialDate: date = date.replace(year = date.year + 1) return date def readPdfBankStatement(filePath): file = filePath.split('/')[-1] fileDatetime = getDatetimeFromFileName(file) content = getTextFromPdf(filePath) lines = content.splitlines() started = False firstPage = True initialAmount = None initialDate = None #currentAmount = None date = None comment = [] transactions = [] debitIndex = None creditIndex = None for line in lines: if not started: # We are interested in the content after this line:) soldeCrediteurAuRegexMatch = SOLDE_CREDITEUR_AU_REGEX.match(line) if COLUMNS_HEADER.match(line) is not None: getIndex = lambda line, type_: line.index(type_) + len(type_) debitIndex = getIndex(line, 'Débit') creditIndex = getIndex(line, 'Crédit') if soldeCrediteurAuRegexMatch is not None or (COLUMNS_HEADER.match(line) is not None and not firstPage): if soldeCrediteurAuRegexMatch is not None: initialDate = datetime.strptime(soldeCrediteurAuRegexMatch.group(1), '%d.%m.%Y') initialAmount = toFloat(soldeCrediteurAuRegexMatch.group(2)) print(f'{initialAmount=}') #currentAmount = initialAmount started = True continue else: # We aren't interested in the content after this line: if line.startswith('BNP PARIBAS SA au capital de') or END_PAGE_AFTER_THE_FIRST_ONE_REGEX.match(line) is not None: firstPage = False started = False continue # We aren't interested in the content after this line else: totalDesOperationsRegexMatch = TOTAL_DES_OPERATIONS_REGEX.match(line) totalDesOperationsCreditOnlyRegexMatch = TOTAL_DES_OPERATIONS_CREDIT_ONLY_REGEX.match(line) if totalDesOperationsRegexMatch is not None or totalDesOperationsCreditOnlyRegexMatch is not None: # Note that transfer between accounts will be noted in both debits and credits, as trying to cancel would make benefits show as negative debit which does not make sense. # Cannot just consider January as benefits only as `20240122.pdf` also contains an additional transfer between my accounts. if totalDesOperationsRegexMatch is not None: totalMonthlyDebit, totalMonthlyCredit = [toFloat(group) for group in totalDesOperationsRegexMatch.groups()] else: totalMonthlyCredit = toFloat(totalDesOperationsCreditOnlyRegexMatch.group(1)) totalMonthlyDebit = 0 print(f'{totalMonthlyDebit=}') print(f'{totalMonthlyCredit=}') break firstLineOfPaymentRegexMatch = FIRST_LINE_OF_PAYMENT_REGEX.match(line) if firstLineOfPaymentRegexMatch is not None: if date is not None: transactions += [{ 'date': getDateFollowing(date, initialDate), 'valeur': getDateFollowing(valeur, initialDate), 'amount': amount, #'currentAmount': currentAmount, 'comment': '\n'.join(comment) }] date = None date, firstCommentLine, valeur, amount = firstLineOfPaymentRegexMatch.groups() lineLen = len(line) amount = toFloat(amount) if abs(debitIndex - lineLen) < abs(creditIndex - lineLen): amount *= -1 #currentAmount += amount comment = [firstCommentLine] elif line != '': comment += [line.strip()] if date is not None: transactions += [{ 'date': getDateFollowing(date, initialDate), 'valeur': getDateFollowing(valeur, initialDate), 'amount': amount, #'currentAmount': currentAmount, 'comment': '\n'.join(comment) }] return initialAmount, totalMonthlyDebit, totalMonthlyCredit, transactions, fileDatetime