Last active
January 9, 2026 08:23
-
-
Save velios/63f67a6d4665767fc2f3486e2d8304ce to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (function () { | |
| const sdk = globalThis.ZenTableImportSdk; | |
| if (!sdk || typeof sdk.registerBankStatementParser !== 'function') { | |
| throw new Error( | |
| 'ZenTableImportSdk is not available. Open ZenTable first and load this file via the plugin loader.' | |
| ); | |
| } | |
| function parseDotDate(dateStr) { | |
| // Формат: "25.12.2025" -> "2025-12-25" | |
| const cleaned = String(dateStr).trim().replace(/\s+/g, ''); | |
| const parts = cleaned.split('.'); | |
| if (parts.length !== 3) return null; | |
| const [dd, mm, yy] = parts; | |
| // Определяем год (может быть 2 или 4 цифры) | |
| let yyyy; | |
| if (yy.length === 2) { | |
| const currentYear = new Date().getFullYear(); | |
| const century = Math.floor(currentYear / 100) * 100; | |
| yyyy = String(century + parseInt(yy, 10)); | |
| } else if (yy.length === 4) { | |
| yyyy = yy; | |
| } else { | |
| return null; | |
| } | |
| if (!dd || !mm || !yyyy) return null; | |
| return `${yyyy}-${mm.padStart(2, '0')}-${dd.padStart(2, '0')}`; | |
| } | |
| function parseAmount(amountStr) { | |
| // Формат: "+2,315.00 ₽" или "-2,315.00 ₽" | |
| const cleaned = String(amountStr) | |
| .replace(/\s+/g, '') | |
| .replace(/₽/g, '') | |
| .replace(/,/g, ''); | |
| const num = parseFloat(cleaned); | |
| return isNaN(num) ? 0 : Math.abs(num); | |
| } | |
| function extractAccountIdentifier(textItems) { | |
| // Ищем номер лицевого счета в начале документа | |
| for (let i = 0; i < textItems.length; i++) { | |
| const item = String(textItems[i]).trim(); | |
| if (item.includes('Номер лицевого счета:')) { | |
| const nextItem = textItems[i + 1]; | |
| if (nextItem) { | |
| const accountNumber = String(nextItem).trim(); | |
| // Берем последние 4 цифры | |
| const last4 = accountNumber.slice(-4); | |
| if (/^\d{4}$/.test(last4)) { | |
| return last4; | |
| } | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| const WildberriesBankParser = { | |
| id: 'wildberries-bank', | |
| detect(textItems) { | |
| const text = textItems.join(' '); | |
| // Проверяем характерные признаки выписки Вайлдберриз Банка | |
| const hasWbBank = text.includes('Вайлдберриз Банк') || text.includes('wb-bank.ru'); | |
| const hasMovementReport = text.includes('Справка о движении средств'); | |
| const hasLicense = text.includes('Лицензия Банка России № 841'); | |
| if (hasWbBank && hasMovementReport) return 1.0; | |
| if (hasWbBank && hasLicense) return 0.9; | |
| if (hasWbBank) return 0.7; | |
| return 0; | |
| }, | |
| parse(textItems) { | |
| const results = []; | |
| const accountIdentifier = extractAccountIdentifier(textItems); | |
| // Ищем строки с датами и суммами | |
| for (let i = 0; i < textItems.length; i++) { | |
| const item = String(textItems[i]).trim(); | |
| // Ищем дату в формате "25.12.20.1" (год может быть неполным из-за форматирования PDF) | |
| const dateMatch = item.match(/^(\d{2})\.+(\d{2})\.+(\d{2})\.+(\d)/); | |
| if (!dateMatch) continue; | |
| // Восстанавливаем полную дату | |
| const dd = dateMatch[1]; | |
| const mm = dateMatch[2]; | |
| const yy = dateMatch[3] + dateMatch[4]; // Соединяем "20" и "1" -> "201" | |
| // Предполагаем, что это 2025 год (берем из контекста документа) | |
| let yyyy = '2025'; | |
| if (parseInt(yy) >= 26) { | |
| yyyy = '20' + yy.substring(0, 2); // "26" -> "2026" | |
| } | |
| const dateStr = `${dd}.${mm}.${yyyy}`; | |
| const dateIso = parseDotDate(dateStr); | |
| if (!dateIso) continue; | |
| // Ищем сумму (следующие несколько элементов) | |
| let amountStr = null; | |
| let description = ''; | |
| let isIncome = false; | |
| for (let j = i + 1; j < Math.min(i + 20, textItems.length); j++) { | |
| const nextItem = String(textItems[j]).trim(); | |
| // Ищем сумму с рублями | |
| if (nextItem.includes('₽') && (nextItem.includes('+') || nextItem.includes('-'))) { | |
| if (!amountStr) { | |
| amountStr = nextItem; | |
| isIncome = nextItem.includes('+'); | |
| } | |
| } | |
| // Собираем описание операции | |
| if (nextItem.includes('Зачисление перевода') || | |
| nextItem.includes('Оплата на Wildberries') || | |
| nextItem.includes('СБП')) { | |
| description = nextItem; | |
| } | |
| // Добавляем дополнительные детали | |
| if (nextItem.includes('Отправитель') || | |
| nextItem.includes('Получатель') || | |
| nextItem.startsWith('79') || | |
| nextItem.includes('БИК')) { | |
| if (description) { | |
| description += ' ' + nextItem; | |
| } | |
| } | |
| // Прерываем поиск, если нашли следующую дату | |
| if (j > i + 1 && /^\d{2}\.+\d{2}\.+\d{2}\.+\d/.test(nextItem)) { | |
| break; | |
| } | |
| } | |
| if (!amountStr) continue; | |
| const amount = parseAmount(amountStr); | |
| if (amount === 0) continue; | |
| // Определяем payee из описания | |
| let payee = 'Операция'; | |
| if (description.includes('Wildberries')) { | |
| payee = 'Wildberries'; | |
| } else if (description.includes('СБП')) { | |
| payee = 'Перевод СБП'; | |
| } | |
| const transaction = { | |
| date: dateIso, | |
| income: isIncome ? amount : 0, | |
| outcome: isIncome ? 0 : amount, | |
| payee: payee, | |
| comment: description.substring(0, 200), // Ограничиваем длину комментария | |
| }; | |
| const meta = {}; | |
| if (accountIdentifier) { | |
| meta.accountHint = { | |
| identifiers: [accountIdentifier], | |
| primaryIdentifier: accountIdentifier, | |
| }; | |
| } | |
| results.push({ transaction, meta }); | |
| } | |
| return results; | |
| }, | |
| }; | |
| sdk.registerBankStatementParser(WildberriesBankParser, { onDuplicate: 'overwrite' }); | |
| console.log('[ZenTable plugin] registered bank parser:', WildberriesBankParser.id); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (function () { | |
| const sdk = globalThis.ZenTableImportSdk; | |
| if (!sdk || typeof sdk.registerBankStatementParser !== 'function') { | |
| throw new Error( | |
| 'ZenTableImportSdk is not available. Open ZenTable first and load this file via the plugin loader.' | |
| ); | |
| } | |
| // Parse Georgian date format DD/MM/YYYY to ISO YYYY-MM-DD | |
| function parseGeorgianDate(dateStr) { | |
| const match = String(dateStr).trim().match(/^(\d{2})\/(\d{2})\/(\d{4})$/); | |
| if (!match) return null; | |
| const [, dd, mm, yyyy] = match; | |
| return `${yyyy}-${mm.padStart(2, '0')}-${dd.padStart(2, '0')}`; | |
| } | |
| // Extract last 4 digits from card numbers like ****3846 | |
| function extractCardIdentifiers(text) { | |
| const matches = String(text).matchAll(/\*{4}(\d{4})/g); | |
| const identifiers = []; | |
| for (const match of matches) { | |
| if (!identifiers.includes(match[1])) { | |
| identifiers.push(match[1]); | |
| } | |
| } | |
| return identifiers; | |
| } | |
| // Extract IBAN last 4 digits | |
| function extractIBANIdentifier(iban) { | |
| const cleaned = String(iban).replace(/\s/g, ''); | |
| if (cleaned.length >= 4) { | |
| return cleaned.slice(-4); | |
| } | |
| return null; | |
| } | |
| const TBCBankParser = { | |
| id: 'tbc-bank-georgia', | |
| detect(textItems) { | |
| const text = textItems.join(' '); | |
| // Look for TBC Bank specific markers | |
| const hasTBCBIC = text.includes('TBCBGE22'); | |
| const hasGeorgianText = text.includes('ამონაწერი ანგარიშიდან') || | |
| text.includes('Account Statement'); | |
| const hasGeorgianIBAN = /GE\d{2}TB\d{18}/.test(text); | |
| if (hasTBCBIC && hasGeorgianText && hasGeorgianIBAN) { | |
| return 1.0; | |
| } | |
| if ((hasTBCBIC && hasGeorgianText) || (hasTBCBIC && hasGeorgianIBAN)) { | |
| return 0.8; | |
| } | |
| if (hasTBCBIC || hasGeorgianIBAN) { | |
| return 0.5; | |
| } | |
| return 0; | |
| }, | |
| parse(textItems) { | |
| const results = []; | |
| const allIdentifiers = new Set(); | |
| // Extract IBAN from header | |
| for (let i = 0; i < Math.min(textItems.length, 20); i++) { | |
| const item = textItems[i]; | |
| const ibanMatch = String(item).match(/GE\d{2}TB\d{18}/); | |
| if (ibanMatch) { | |
| const identifier = extractIBANIdentifier(ibanMatch[0]); | |
| if (identifier) { | |
| allIdentifiers.add(identifier); | |
| } | |
| break; | |
| } | |
| } | |
| // Parse transactions | |
| for (let i = 0; i < textItems.length; i++) { | |
| const dateStr = textItems[i]; | |
| const dateIso = parseGeorgianDate(dateStr); | |
| if (!dateIso) continue; | |
| // Look ahead for description and amounts | |
| let description = ''; | |
| let additionalInfo = ''; | |
| let paidOut = 0; | |
| let paidIn = 0; | |
| // Scan next items for transaction details | |
| for (let j = i + 1; j < Math.min(i + 20, textItems.length); j++) { | |
| const item = String(textItems[j]).trim(); | |
| // Stop if we hit another date | |
| if (parseGeorgianDate(item)) { | |
| break; | |
| } | |
| // Collect description (usually multi-line Georgian + English) | |
| if (item.length > 5 && !item.match(/^\d+\.\d{2}$/) && item !== 'GEL') { | |
| if (description.length < 200) { | |
| description += (description ? ' ' : '') + item; | |
| } | |
| } | |
| // Extract card identifiers from description | |
| const cardIds = extractCardIdentifiers(item); | |
| cardIds.forEach(id => allIdentifiers.add(id)); | |
| // Look for amounts (format: "2.00", "100.00") | |
| const amountMatch = item.match(/^(\d+\.\d{2})$/); | |
| if (amountMatch) { | |
| const amount = parseFloat(amountMatch[1]); | |
| // Determine if it's paid out or paid in based on context | |
| // The pattern is: Date, Description, Additional Info, Paid Out, Paid In, Balance | |
| const nextItem = textItems[j + 1]; | |
| const nextAmount = nextItem ? String(nextItem).match(/^(\d+\.\d{2})$/) : null; | |
| if (paidOut === 0 && nextAmount) { | |
| // This is likely Paid Out, next is Paid In | |
| paidOut = amount; | |
| } else if (paidOut > 0 && paidIn === 0) { | |
| // This is Paid In | |
| paidIn = amount; | |
| break; // We have all amounts | |
| } else if (paidOut === 0 && paidIn === 0 && !nextAmount) { | |
| // Only one amount, need to determine direction | |
| // Check if description suggests income or expense | |
| if (description.includes('Transfer between your accounts') && | |
| description.includes('GE60TB7481536515100054')) { | |
| paidIn = amount; | |
| } else { | |
| paidOut = amount; | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| // Create transaction if we have valid data | |
| if (description && (paidOut > 0 || paidIn > 0)) { | |
| // Clean up description | |
| description = description | |
| .replace(/\s+/g, ' ') | |
| .replace(/,\s*TBCBGE22.*$/, '') | |
| .trim(); | |
| // Extract payee | |
| let payee = description.split(/[,;]/)[0].trim(); | |
| if (payee.length > 100) { | |
| payee = payee.substring(0, 100); | |
| } | |
| results.push({ | |
| transaction: { | |
| date: dateIso, | |
| income: paidIn, | |
| outcome: paidOut, | |
| payee: payee || 'TBC Bank Transaction', | |
| originalPayee: payee, | |
| comment: description.length > payee.length ? description : undefined, | |
| }, | |
| meta: { | |
| accountHint: { | |
| identifiers: Array.from(allIdentifiers), | |
| primaryIdentifier: Array.from(allIdentifiers)[0], | |
| }, | |
| }, | |
| }); | |
| } | |
| } | |
| return results; | |
| }, | |
| }; | |
| sdk.registerBankStatementParser(TBCBankParser, { onDuplicate: 'overwrite' }); | |
| console.log('[ZenTable plugin] registered bank parser:', TBCBankParser.id); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment