diff --git a/src/plugins/vakifbank-tr/__tests__/statementTransaction.test.ts b/src/plugins/vakifbank-tr/__tests__/statementTransaction.test.ts index da2ad856..8d0a167c 100644 --- a/src/plugins/vakifbank-tr/__tests__/statementTransaction.test.ts +++ b/src/plugins/vakifbank-tr/__tests__/statementTransaction.test.ts @@ -1,4 +1,5 @@ import { AccountType } from '../../../types/zenmoney' +import { extractInstrument } from '../api' import { convertVakifPdfStatementTransaction } from '../converters' import { TransactionWithId, VakifStatementTransaction } from '../models' @@ -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') +}) diff --git a/src/plugins/vakifbank-tr/api.ts b/src/plugins/vakifbank-tr/api.ts index fb555997..57dcdcf2 100644 --- a/src/plugins/vakifbank-tr/api.ts +++ b/src/plugins/vakifbank-tr/api.ts @@ -5,21 +5,16 @@ import { parsePdfFromBlob } from './pdfToStr' import { parseDateAndTimeFromPdfText, parseDateFromPdfText, parseFormattedNumber } from './converters' export async function parsePdfVakifStatement (): Promise> { - 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 { @@ -32,12 +27,34 @@ export async function parsePdfVakifStatement (): Promise= 1000 * 1000) { + throw new TemporaryError('Максимальный размер файла - 1 мб') + } + } +} + +async function getPdfDocuments (): Promise { + 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) } @@ -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) diff --git a/src/plugins/vakifbank-tr/converters.ts b/src/plugins/vakifbank-tr/converters.ts index 09c1256e..9bcccf92 100644 --- a/src/plugins/vakifbank-tr/converters.ts +++ b/src/plugins/vakifbank-tr/converters.ts @@ -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(',', '.')) } @@ -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,