Skip to content

Instantly share code, notes, and snippets.

@velios
Last active January 9, 2026 08:23
Show Gist options
  • Select an option

  • Save velios/63f67a6d4665767fc2f3486e2d8304ce to your computer and use it in GitHub Desktop.

Select an option

Save velios/63f67a6d4665767fc2f3486e2d8304ce to your computer and use it in GitHub Desktop.
(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);
})();
(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