Skip to content

Commit

Permalink
[vakifbank-tr] Fix bugs with pdf parser (#783)
Browse files Browse the repository at this point in the history
  • Loading branch information
AmirL authored Nov 11, 2024
1 parent a4f70d0 commit e1eec02
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 71 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AccountType } from '../../../types/zenmoney'
import { extractInstrument } from '../api'

import { convertVakifPdfStatementTransaction } from '../converters'
import { TransactionWithId, VakifStatementTransaction } from '../models'
Expand Down Expand Up @@ -170,3 +171,8 @@ const testCases: Array<[VakifStatementTransaction[], TransactionWithId[]]> = [
it.each(testCases)('converts transaction parsed from pdf statement', (rawTransaction: VakifStatementTransaction[], transaction: TransactionWithId[]) => {
expect(convertVakifPdfStatementTransaction('1234567890', rawTransaction)).toEqual(transaction)
})

it('can extract account instrument code', () => {
expect(extractInstrument(' Account Type:VADESİZ USDNickname: ')).toEqual('USD')
expect(extractInstrument(' Account Type:VADESİZ TLNickname: ')).toEqual('TL')
})
48 changes: 35 additions & 13 deletions src/plugins/vakifbank-tr/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,16 @@ import { parsePdfFromBlob } from './pdfToStr'
import { parseDateAndTimeFromPdfText, parseDateFromPdfText, parseFormattedNumber } from './converters'

export async function parsePdfVakifStatement (): Promise<null | Array<{ account: VakifStatementAccount, transactions: VakifStatementTransaction[] }>> {
const blob = await ZenMoney.pickDocuments(['application/pdf'], true)
if (blob == null || !blob.length) {
throw new TemporaryError('Выберите один или несколько файлов в формате .pdf')
}
for (const { size, type } of blob) {
if (type !== 'application/pdf') {
throw new TemporaryError('Выписка должна быть в расширении .pdf')
} else if (size >= 1000 * 1000) {
throw new TemporaryError('Максимальный размер файла - 1 мб')
}
}
const blob = await getPdfDocuments()
validateDocuments(blob)
const pdfStrings = await parsePdfFromBlob({ blob })
return parsePdfStatements(pdfStrings)
}

export function parsePdfStatements (pdfStrings: string[]): Array<{ account: VakifStatementAccount, transactions: VakifStatementTransaction[] }> {
const result = []
for (const textItem of pdfStrings) {
if (!/www.vakifbank.com.tr/i.test(textItem)) {
if (!isVakifBankStatement(textItem)) {
throw new TemporaryError('Похоже, это не выписка VakifBank')
}
try {
Expand All @@ -32,12 +27,34 @@ export async function parsePdfVakifStatement (): Promise<null | Array<{ account:
return result
}

function isVakifBankStatement (text: string): boolean {
return /www.vakifbank.com.tr/i.test(text)
}

export function validateDocuments (blob: Blob[]): void {
for (const { size, type } of blob) {
if (type !== 'application/pdf') {
throw new TemporaryError('Выписка должна быть в расширении .pdf')
} else if (size >= 1000 * 1000) {
throw new TemporaryError('Максимальный размер файла - 1 мб')
}
}
}

async function getPdfDocuments (): Promise<Blob[]> {
const blob = await ZenMoney.pickDocuments(['application/pdf'], true)
if (blob == null || !blob.length) {
throw new TemporaryError('Выберите один или несколько файлов в формате .pdf')
}
return blob
}

export function parseSinglePdfString (text: string, statementUid?: string): { account: VakifStatementAccount, transactions: VakifStatementTransaction[] } {
const balanceAmount = extractBalance(text)
const rawAccount: VakifStatementAccount = {
balance: balanceAmount,
id: extractAccountId(text),
instrument: 'TL',
instrument: extractInstrument(text),
title: 'Vakifbank *' + extractAccountId(text).slice(-4),
date: extractStatementDate(text)
}
Expand All @@ -49,6 +66,11 @@ export function parseSinglePdfString (text: string, statementUid?: string): { ac
return parsedContent
}

export function extractInstrument (text: string): string {
const match = text.match(/Account Type:VADESİZ\s+([A-Z]{2,3})Nickname:/)
return match ? match[1] : 'TL' // Default to 'TL' if no match found
}

function extractAccountId (text: string): string {
const accountNumRegex = /Account No: (\d{17})/
const match = accountNumRegex.exec(text)
Expand Down
152 changes: 94 additions & 58 deletions src/plugins/vakifbank-tr/converters.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { groupBy, maxBy, minBy } from 'lodash'

import { Amount, AccountType, NonParsedMerchant, AccountOrCard } from '../../types/zenmoney'
import { Amount, AccountType, NonParsedMerchant, AccountOrCard, Transaction } from '../../types/zenmoney'
import { TransactionWithId, VakifStatementAccount, VakifStatementTransaction } from './models'

const MERCHANT_TITLE_REGEX = /ISLEM NO :\s*(\d{4})?-(.*?)\s\s(.*?)\*\*\*\*/
const MERCHANT_MCC_REGEX = /Mcc: (\d{4})/

export function parseFormattedNumber (value: string): number {
return parseFloat(value.replace(/\./g, '').replace(',', '.'))
}
Expand Down Expand Up @@ -32,68 +35,101 @@ export function convertVakifPdfStatementTransaction (accountId: string, rawTrans

const chunks = chunksByStatementUid(rawTransaction)
for (const transactions of chunks) {
if (transactions.length === 1 || transactions.length === 2) {
const mainTransaction = maxBy(transactions, x => Math.abs(parseFormattedNumber(x.amount)))
const feeTransaction = transactions.length === 1 ? null : minBy(transactions, x => Math.abs(parseFormattedNumber(x.amount)))
const transaction = mainTransaction
if (transaction == null) { throw new Error('InvalidState') }
let merchant: NonParsedMerchant | null = null
let comment: string | null = null

const merchanTitleRegEx = /ISLEM NO :\s*(\d{4})?-(.*?)\s\s(.*?)\*\*\*\*/
const merchantMccRegEx = /Mcc: (\d{4})/
const merchanTitleMatch = merchanTitleRegEx.exec(transaction.description2 ?? '')
const merchantMccMatch = merchantMccRegEx.exec(transaction.description2 ?? '')

if (merchanTitleMatch != null || merchantMccMatch != null) {
merchant = {
location: null,
fullTitle: merchanTitleMatch?.[2] ?? '',
mcc: merchantMccMatch == null ? null : parseInt(merchantMccMatch[1], 10)
}
} else {
comment = (transaction.description1 ?? '') + ': ' + (transaction.description2 ?? '')
}

result.push({
statementUid: transaction.statementUid,
transaction: {
comment,
date: new Date(transaction.date),
hold: false,
merchant,
movements: [
{
account: { id: accountId },
fee: feeTransaction == null ? 0 : parseFormattedNumber(feeTransaction.amount),
id: transaction.statementUid,
sum: parseFormattedNumber(transaction.amount),
invoice: null
}
]
}
})
if (transaction.description1 === 'ATM Withdrawal') {
result[0].transaction.comment = transaction.description2
result[0].transaction.movements.push({
account: {
company: null,
instrument: 'TL',
syncIds: null,
type: AccountType.cash
},
fee: 0,
id: transaction.statementUid,
sum: parseFormattedNumber(transaction.amount) * -1,
invoice: null
})
}
}
if (transactions.length !== 1 && transactions.length !== 2) continue

const transaction = buildTransaction(accountId, transactions)
result.push(transaction)
}

return result
}

function buildTransaction (accountId: string, transactions: VakifStatementTransaction[]): TransactionWithId {
const mainTransaction = getMainTransaction(transactions)
const feeAmount = getFeeAmount(transactions)
const merchant = extractMerchantInfo(mainTransaction)
const comment = merchant ? null : `${mainTransaction.description1 ?? ''}: ${mainTransaction.description2 ?? ''}`

const transaction: Transaction = {
comment,
date: new Date(mainTransaction.date),
hold: false,
merchant,
movements: [
createMainMovement(accountId, mainTransaction, feeAmount)
]
}

if (mainTransaction.description1 === 'ATM Withdrawal' || mainTransaction.description1 === 'ATM QR Withdrawal') {
transaction.comment = mainTransaction.description2
transaction.movements.push(createOppositeMovement(mainTransaction, AccountType.cash))
}

return {
statementUid: mainTransaction.statementUid,
transaction
}
}

function getMainTransaction (transactions: VakifStatementTransaction[]): VakifStatementTransaction {
const mainTransaction = maxBy(transactions, x => Math.abs(parseFormattedNumber(x.amount)))
if (!mainTransaction) throw new Error('InvalidState')
return mainTransaction
}

function getFeeAmount (transactions: VakifStatementTransaction[]): number {
if (transactions.length !== 2) return 0

const feeTransaction = minBy(transactions, x => Math.abs(parseFormattedNumber(x.amount)))
return feeTransaction ? parseFormattedNumber(feeTransaction.amount) : 0
}

function createMainMovement (
accountId: string,
transaction: VakifStatementTransaction,
feeAmount: number
): Transaction['movements'][number] {
return {
account: { id: accountId },
fee: feeAmount,
id: transaction.statementUid,
sum: parseFormattedNumber(transaction.amount),
invoice: null
}
}

function createOppositeMovement (
transaction: VakifStatementTransaction,
accountType: AccountType
): Transaction['movements'][number] {
return {
account: {
company: null,
instrument: 'TL',
syncIds: null,
type: accountType
},
fee: 0,
id: transaction.statementUid,
sum: parseFormattedNumber(transaction.amount) * -1,
invoice: null
}
}

function extractMerchantInfo (transaction: VakifStatementTransaction): NonParsedMerchant | null {
const merchanTitleMatch = MERCHANT_TITLE_REGEX.exec(transaction.description2 ?? '')
const merchantMccMatch = MERCHANT_MCC_REGEX.exec(transaction.description2 ?? '')

if (merchanTitleMatch != null || merchantMccMatch != null) {
return {
location: null,
fullTitle: merchanTitleMatch?.[2] ?? '',
mcc: merchantMccMatch ? parseInt(merchantMccMatch[1], 10) : null
}
}
return null
}

export function convertPdfStatementAccount (rawAccount: VakifStatementAccount): AccountOrCard {
const account: AccountOrCard = {
id: rawAccount.id,
Expand Down

0 comments on commit e1eec02

Please sign in to comment.