diff --git a/src/plugins/vtb-by/ZenmoneyManifest.xml b/src/plugins/vtb-by/ZenmoneyManifest.xml
new file mode 100644
index 000000000..9daa24010
--- /dev/null
+++ b/src/plugins/vtb-by/ZenmoneyManifest.xml
@@ -0,0 +1,13 @@
+
+
+ vtb-by
+ 0
+ 1.0
+ 1
+ true
+
+ index.js
+ preferences.xml
+
+ false
+
diff --git a/src/plugins/vtb-by/__tests__/converters/convert-card-account.test.ts b/src/plugins/vtb-by/__tests__/converters/convert-card-account.test.ts
new file mode 100644
index 000000000..71b94583a
--- /dev/null
+++ b/src/plugins/vtb-by/__tests__/converters/convert-card-account.test.ts
@@ -0,0 +1,54 @@
+import { AccountType } from '../../../../types/zenmoney'
+import { convertCardAccount } from '../../converters'
+import type { FetchCardAccount } from '../../types/fetch'
+
+describe('convertCardAccount', () => {
+ it('converts a VTB card account', () => {
+ const account: FetchCardAccount = {
+ internalAccountId: 'internal-card-account-1',
+ currency: '933',
+ openDate: 1627506000000,
+ accountNumber: 'TEST-CARD-ACCOUNT-0001',
+ cardAccountNumber: 'card-account-0001',
+ productCode: 'CARD-TEST',
+ productName: 'Тестовая карта BYN',
+ contractId: 'contract-card-1',
+ interestRate: 0.01,
+ accountStatus: 'INACTIVE',
+ ibanNum: 'TEST-IBAN-CARD-0001',
+ cards: [
+ {
+ cardNumberMasked: '555544******1111',
+ cardHash: 'card-hash',
+ cardStatus: 'CLOSED',
+ cardStatusCode: 9,
+ owner: 'TEST USER',
+ tariffName: 'Test BYN',
+ balance: 0,
+ payment: '0',
+ accountId: 'card-account-0001',
+ stateSignature: 'CLOSED',
+ cardProductId: 162,
+ cardId: 857411864,
+ salary: false,
+ virtual: false
+ }
+ ]
+ }
+
+ expect(convertCardAccount(account)).toEqual({
+ id: 'contract-card-1',
+ title: 'Тестовая карта BYN *1111',
+ balance: 0,
+ instrument: 'BYN',
+ syncIds: ['TEST-IBAN-CARD-0001', 'TEST-CARD-ACCOUNT-0001', 'contract-card-1', '1111'],
+ type: AccountType.ccard,
+ archived: true,
+ _meta: {
+ productKind: 'card',
+ statementInternalAccountId: 'internal-card-account-1',
+ statementCardHash: 'card-hash'
+ }
+ })
+ })
+})
diff --git a/src/plugins/vtb-by/__tests__/converters/convert-card-statement-operation.test.ts b/src/plugins/vtb-by/__tests__/converters/convert-card-statement-operation.test.ts
new file mode 100644
index 000000000..d3f105df3
--- /dev/null
+++ b/src/plugins/vtb-by/__tests__/converters/convert-card-statement-operation.test.ts
@@ -0,0 +1,46 @@
+import { Account, AccountType } from '../../../../types/zenmoney'
+import { convertMiniCardStatementOperation } from '../../converters'
+import type { FetchMiniCardStatementOperation } from '../../types/fetch'
+
+describe('convertMiniCardStatementOperation', () => {
+ it('uses transaction amount sign for a VTB mini card statement operation', () => {
+ const operation: FetchMiniCardStatementOperation = {
+ operationDate: 1777561115000,
+ operationDescription: 'Покупка',
+ operationAmount: 32,
+ operationCurrency: '933',
+ operationPlace: 'STORE',
+ operationState: 1,
+ transactionAmount: -10,
+ transactionCurrency: '840',
+ transactionAuthCode: '999'
+ }
+
+ const account: Account = {
+ id: 'test-card-contract-1',
+ type: AccountType.ccard,
+ title: 'Тестовая карта BYN *1111',
+ instrument: 'BYN',
+ syncIds: ['TEST-IBAN-CARD-0001']
+ }
+
+ expect(convertMiniCardStatementOperation(operation, account)).toEqual({
+ hold: null,
+ date: new Date(1777561115000),
+ comment: 'Покупка\nSTORE',
+ movements: [
+ {
+ id: 'test-card-contract-1:1777561115000:999:-10:32:Покупка',
+ account: { id: 'test-card-contract-1' },
+ fee: 0,
+ invoice: {
+ sum: -10,
+ instrument: 'USD'
+ },
+ sum: -32
+ }
+ ],
+ merchant: null
+ })
+ })
+})
diff --git a/src/plugins/vtb-by/__tests__/converters/convert-current-account.test.ts b/src/plugins/vtb-by/__tests__/converters/convert-current-account.test.ts
new file mode 100644
index 000000000..c7d19835e
--- /dev/null
+++ b/src/plugins/vtb-by/__tests__/converters/convert-current-account.test.ts
@@ -0,0 +1,36 @@
+import { AccountType } from '../../../../types/zenmoney'
+import { convertCurrentAccount } from '../../converters'
+import type { FetchCurrentAccount } from '../../types/fetch'
+
+describe('convertCurrentAccount', () => {
+ it('converts a VTB current account', () => {
+ const account: FetchCurrentAccount = {
+ internalAccountId: 'internal-current-account-1',
+ currency: '643',
+ openDate: 1736283600000,
+ accountNumber: 'TEST-CURRENT-ACCOUNT-0001',
+ productCode: 'CURRENT-TEST',
+ productName: 'Тестовый текущий счет',
+ balanceAmount: 0,
+ contractId: 'contract-current-1',
+ interestRate: 0.01,
+ accountStatus: 'OPEN',
+ ibanNum: 'TEST-IBAN-CURRENT-0001'
+ }
+
+ expect(convertCurrentAccount(account)).toEqual({
+ id: 'contract-current-1',
+ title: 'Тестовый текущий счет',
+ balance: 0,
+ instrument: 'RUB',
+ syncIds: ['TEST-IBAN-CURRENT-0001', 'TEST-CURRENT-ACCOUNT-0001', 'contract-current-1'],
+ type: AccountType.checking,
+ archived: false,
+ _meta: {
+ productKind: 'current',
+ statementInternalAccountId: null,
+ statementCardHash: null
+ }
+ })
+ })
+})
diff --git a/src/plugins/vtb-by/__tests__/converters/convert-deposit-account.test.ts b/src/plugins/vtb-by/__tests__/converters/convert-deposit-account.test.ts
new file mode 100644
index 000000000..4aca97514
--- /dev/null
+++ b/src/plugins/vtb-by/__tests__/converters/convert-deposit-account.test.ts
@@ -0,0 +1,47 @@
+import { AccountType } from '../../../../types/zenmoney'
+import { convertDepositAccount } from '../../converters'
+import type { FetchDepositAccount } from '../../types/fetch'
+
+describe('convertDepositAccount', () => {
+ it('converts a VTB deposit account', () => {
+ const account: FetchDepositAccount = {
+ internalAccountId: 'internal-deposit-account-1',
+ currency: '643',
+ openDate: 1736283600000,
+ endDate: 1857243600000,
+ accountNumber: 'TEST-DEPOSIT-ACCOUNT-0001',
+ productCode: 'DEPOSIT-TEST',
+ productName: '"Тестовый вклад", RUB с капитализацией',
+ balanceAmount: 1225.71,
+ contractId: 'contract-deposit-1',
+ interestRate: 15.9,
+ accountStatus: 'OPEN',
+ ibanNum: 'TEST-IBAN-DEPOSIT-0001',
+ personalizedName: 'Тестовый вклад'
+ }
+
+ const converted = convertDepositAccount(account)
+
+ expect(converted.id).toBe('contract-deposit-1')
+ expect(converted.type).toBe(AccountType.deposit)
+
+ if (converted.type !== AccountType.deposit) {
+ throw new Error('Expected deposit account')
+ }
+
+ expect(converted.title).toBe('Тестовый вклад')
+ expect(converted.instrument).toBe('RUB')
+ expect(converted.syncIds).toEqual(['TEST-IBAN-DEPOSIT-0001', 'TEST-DEPOSIT-ACCOUNT-0001', 'contract-deposit-1'])
+ expect(converted.balance).toBe(1225.71)
+ expect(converted.percent).toBe(15.9)
+ expect(converted.capitalization).toBe(true)
+ expect(converted.payoffInterval).toBe('month')
+ expect(converted.payoffStep).toBe(1)
+ expect(converted.archived).toBe(false)
+ expect(converted._meta).toEqual({
+ productKind: 'deposit',
+ statementInternalAccountId: null,
+ statementCardHash: null
+ })
+ })
+})
diff --git a/src/plugins/vtb-by/__tests__/converters/should-sync-card-account.test.ts b/src/plugins/vtb-by/__tests__/converters/should-sync-card-account.test.ts
new file mode 100644
index 000000000..792578280
--- /dev/null
+++ b/src/plugins/vtb-by/__tests__/converters/should-sync-card-account.test.ts
@@ -0,0 +1,55 @@
+import { shouldSyncCardAccount } from '../../converters'
+import type { FetchCardAccount } from '../../types/fetch'
+
+const makeAccount = (overrides: Partial = {}): FetchCardAccount => ({
+ internalAccountId: 'internal-card-account-1',
+ currency: '933',
+ openDate: 1627506000000,
+ accountNumber: 'TEST-CARD-ACCOUNT-0001',
+ cardAccountNumber: 'card-account-0001',
+ productCode: 'CARD-TEST',
+ productName: 'Тестовая карта BYN',
+ contractId: 'contract-card-1',
+ interestRate: 0.01,
+ accountStatus: 'OPEN',
+ ibanNum: 'TEST-IBAN-CARD-0001',
+ cards: [
+ {
+ cardNumberMasked: '555544******1111',
+ cardHash: 'card-hash',
+ cardStatus: 'OPEN',
+ cardStatusCode: 1,
+ owner: 'TEST USER',
+ tariffName: 'Test BYN',
+ balance: 0,
+ payment: '0',
+ accountId: 'card-account-0001',
+ stateSignature: 'OPEN',
+ cardProductId: 162,
+ cardId: 857411864,
+ salary: false,
+ virtual: false
+ }
+ ],
+ ...overrides
+})
+
+describe('shouldSyncCardAccount', () => {
+ it('returns false for closed card accounts', () => {
+ expect(shouldSyncCardAccount(makeAccount({
+ accountStatus: 'INACTIVE',
+ cards: [
+ {
+ ...makeAccount().cards[0],
+ cardStatus: 'CLOSED',
+ cardStatusCode: 9,
+ stateSignature: 'CLOSED'
+ }
+ ]
+ }))).toBe(false)
+ })
+
+ it('returns true for open card accounts with active cards', () => {
+ expect(shouldSyncCardAccount(makeAccount())).toBe(true)
+ })
+})
diff --git a/src/plugins/vtb-by/api.ts b/src/plugins/vtb-by/api.ts
new file mode 100644
index 000000000..c836ac391
--- /dev/null
+++ b/src/plugins/vtb-by/api.ts
@@ -0,0 +1,87 @@
+import { BankMessageError, InvalidLoginOrPasswordError } from '../../errors'
+import type { Account, Transaction } from '../../types/zenmoney'
+import { convertCardAccount, convertCurrentAccount, convertDepositAccount, convertMiniCardStatementOperation, shouldSyncCardAccount } from './converters'
+import { fetchAccountsOverview, fetchLogin, fetchMiniCardStatement } from './fetchApi'
+import { getMiniStatementIntervals, isDateInRange } from './helpers'
+import type { FetchAccountMeta, ResponseWithErrorInfo } from './types/base'
+import type { FetchMiniCardStatementOperation } from './types/fetch'
+
+const INVALID_LOGIN_ERROR_CODES = new Set(['10008', '10812'])
+
+const assertSuccessfulResponse = (response: ResponseWithErrorInfo, context: string): void => {
+ if (response.errorInfo.error === '0') return
+
+ console.error(`[${context}] Error`, response.errorInfo)
+
+ throw new BankMessageError(response.errorInfo.errorDescription ?? response.errorInfo.errorText)
+}
+
+export const authenticate = async (login: string, password: string): Promise<{ sessionToken: string }> => {
+ const response = await fetchLogin({ login, password })
+
+ if (response.errorInfo.error !== '0') {
+ if (INVALID_LOGIN_ERROR_CODES.has(response.errorInfo.error)) {
+ throw new InvalidLoginOrPasswordError()
+ }
+
+ throw new BankMessageError(response.errorInfo.errorDescription ?? response.errorInfo.errorText)
+ }
+
+ return {
+ sessionToken: response.sessionToken
+ }
+}
+
+export const getAccounts = async ({ sessionToken }: { sessionToken: string }): Promise> => {
+ const response = await fetchAccountsOverview({ sessionToken })
+
+ assertSuccessfulResponse(response, 'GET_ACCOUNTS')
+
+ return [
+ ...(response.overviewResponse.cardAccount ?? [])
+ .filter((account) => shouldSyncCardAccount(account))
+ .map((account) => convertCardAccount(account)),
+ ...(response.overviewResponse.currentAccount ?? []).map((account) => convertCurrentAccount(account)),
+ ...(response.overviewResponse.depositAccount ?? []).map((account) => convertDepositAccount(account))
+ ]
+}
+
+export const getTransactions = async (
+ { sessionToken, fromDate, toDate }: { sessionToken: string, fromDate: Date, toDate: Date | undefined },
+ account: Account & FetchAccountMeta
+): Promise => {
+ if (account._meta.productKind !== 'card' || account._meta.statementCardHash == null) {
+ return []
+ }
+
+ const operations: FetchMiniCardStatementOperation[] = []
+
+ for (const interval of getMiniStatementIntervals(fromDate, toDate)) {
+ const response = await fetchMiniCardStatement({
+ sessionToken,
+ cardHash: account._meta.statementCardHash,
+ from: interval.from,
+ till: interval.till
+ })
+
+ assertSuccessfulResponse(response, 'GET_TRANSACTIONS')
+ operations.push(...(response.statement ?? []))
+ }
+
+ const seenIds = new Set()
+
+ return operations
+ .filter((operation) => operation.operationAmount !== 0 || operation.transactionAmount !== 0)
+ .filter((operation) => isDateInRange(
+ new Date(operation.operationDate),
+ fromDate,
+ toDate
+ ))
+ .map((operation) => convertMiniCardStatementOperation(operation, account))
+ .filter((transaction) => {
+ const movementId = transaction.movements[0]?.id
+ if (movementId == null || seenIds.has(movementId)) return false
+ seenIds.add(movementId)
+ return true
+ })
+}
diff --git a/src/plugins/vtb-by/converters.ts b/src/plugins/vtb-by/converters.ts
new file mode 100644
index 000000000..0bc3e8469
--- /dev/null
+++ b/src/plugins/vtb-by/converters.ts
@@ -0,0 +1,209 @@
+import { getIntervalBetweenDates } from '../../common/momentDateUtils'
+import { Account, AccountType, Transaction } from '../../types/zenmoney'
+import { ensureCurrency, getMaskedCardLastDigits, isNonEmptyString } from './helpers'
+import type { FetchAccountMeta } from './types/base'
+import type { FetchCardAccount, FetchCardStatementOperation, FetchCurrentAccount, FetchDepositAccount, FetchMiniCardStatementOperation } from './types/fetch'
+
+const getActiveCards = (cards: FetchCardAccount['cards']): FetchCardAccount['cards'] =>
+ cards.filter((card) => card.cardStatus !== 'CLOSED')
+
+export const shouldSyncCardAccount = (fetchAccount: FetchCardAccount): boolean =>
+ fetchAccount.accountStatus === 'OPEN' && getActiveCards(fetchAccount.cards).length > 0
+
+const getCardAccountTitle = (fetchAccount: FetchCardAccount): string => {
+ const [card] = getActiveCards(fetchAccount.cards).length > 0
+ ? getActiveCards(fetchAccount.cards)
+ : fetchAccount.cards
+
+ if (card == null) return fetchAccount.productName
+
+ const suffix = getMaskedCardLastDigits(card.cardNumberMasked)
+ return suffix.length > 0
+ ? `${fetchAccount.productName} *${suffix}`
+ : fetchAccount.productName
+}
+
+export const convertCardAccount = (fetchAccount: FetchCardAccount): Account & FetchAccountMeta => {
+ if (fetchAccount.cards.length === 0) throw new Error('No cards linked to card account')
+
+ const currency = ensureCurrency(fetchAccount.currency)
+
+ if (!isNonEmptyString(currency)) throw new Error(`Unknown currency - ${fetchAccount.currency}`)
+
+ const [balanceCard] = getActiveCards(fetchAccount.cards).length > 0
+ ? getActiveCards(fetchAccount.cards)
+ : fetchAccount.cards
+
+ return {
+ id: fetchAccount.contractId,
+ title: getCardAccountTitle(fetchAccount),
+ balance: balanceCard.balance,
+ instrument: currency,
+ syncIds: [
+ fetchAccount.ibanNum,
+ fetchAccount.accountNumber,
+ fetchAccount.contractId,
+ ...fetchAccount.cards
+ .map((card) => getMaskedCardLastDigits(card.cardNumberMasked))
+ .filter(isNonEmptyString)
+ ],
+ type: AccountType.ccard,
+ archived: fetchAccount.accountStatus !== 'OPEN' || getActiveCards(fetchAccount.cards).length === 0,
+ _meta: {
+ productKind: 'card',
+ statementInternalAccountId: fetchAccount.internalAccountId,
+ statementCardHash: balanceCard.cardHash
+ }
+ }
+}
+
+export const convertCurrentAccount = (fetchAccount: FetchCurrentAccount): Account & FetchAccountMeta => {
+ const currency = ensureCurrency(fetchAccount.currency)
+
+ if (!isNonEmptyString(currency)) throw new Error(`Unknown currency - ${fetchAccount.currency}`)
+
+ return {
+ id: fetchAccount.contractId,
+ title: fetchAccount.productName,
+ balance: fetchAccount.balanceAmount,
+ instrument: currency,
+ syncIds: [fetchAccount.ibanNum, fetchAccount.accountNumber, fetchAccount.contractId],
+ type: AccountType.checking,
+ archived: fetchAccount.accountStatus !== 'OPEN',
+ _meta: {
+ productKind: 'current',
+ statementInternalAccountId: null,
+ statementCardHash: null
+ }
+ }
+}
+
+export const convertDepositAccount = (fetchAccount: FetchDepositAccount): Account & FetchAccountMeta => {
+ const currency = ensureCurrency(fetchAccount.currency)
+
+ if (!isNonEmptyString(currency)) throw new Error(`Unknown currency - ${fetchAccount.currency}`)
+
+ const startDate = new Date(fetchAccount.openDate)
+ const endDate = new Date(fetchAccount.endDate)
+ const { count: endDateOffset, interval: endDateOffsetInterval } = getIntervalBetweenDates(startDate, endDate)
+
+ return {
+ id: fetchAccount.contractId,
+ title: fetchAccount.personalizedName ?? fetchAccount.productName,
+ balance: fetchAccount.balanceAmount,
+ instrument: currency,
+ syncIds: [fetchAccount.ibanNum, fetchAccount.accountNumber, fetchAccount.contractId],
+ type: AccountType.deposit,
+ startDate,
+ startBalance: fetchAccount.balanceAmount,
+ capitalization: /капитал/i.test(fetchAccount.productName),
+ percent: fetchAccount.interestRate,
+ endDateOffsetInterval,
+ endDateOffset,
+ payoffInterval: 'month',
+ payoffStep: 1,
+ archived: fetchAccount.accountStatus !== 'OPEN',
+ _meta: {
+ productKind: 'deposit',
+ statementInternalAccountId: null,
+ statementCardHash: null
+ }
+ }
+}
+
+const getSignedAmount = (amount: number, fallbackAmount: number): number => {
+ if (amount !== 0) return amount
+ return fallbackAmount
+}
+
+const getMiniStatementSign = (fetchTransaction: FetchMiniCardStatementOperation): number => {
+ if (fetchTransaction.transactionAmount !== 0) {
+ return Math.sign(fetchTransaction.transactionAmount)
+ }
+
+ if (fetchTransaction.operationAmount !== 0) {
+ return Math.sign(fetchTransaction.operationAmount)
+ }
+
+ return 1
+}
+
+export const convertMiniCardStatementOperation = (fetchTransaction: FetchMiniCardStatementOperation, account: Account): Transaction => {
+ const operationCurrency = ensureCurrency(fetchTransaction.operationCurrency) ?? account.instrument
+ const transactionCurrency = ensureCurrency(fetchTransaction.transactionCurrency) ?? operationCurrency
+ const sign = getMiniStatementSign(fetchTransaction)
+ const operationAmount = Math.abs(getSignedAmount(fetchTransaction.operationAmount, fetchTransaction.transactionAmount)) * sign
+ const transactionAmount = Math.abs(getSignedAmount(fetchTransaction.transactionAmount, fetchTransaction.operationAmount)) * sign
+ const hasInvoice = transactionCurrency !== account.instrument
+
+ return {
+ hold: null,
+ date: new Date(fetchTransaction.operationDate),
+ comment: [fetchTransaction.operationDescription, fetchTransaction.operationPlace]
+ .filter(isNonEmptyString)
+ .join('\n'),
+ movements: [
+ {
+ id: [
+ account.id,
+ fetchTransaction.operationDate,
+ fetchTransaction.transactionAuthCode ?? '',
+ fetchTransaction.transactionAmount,
+ fetchTransaction.operationAmount,
+ fetchTransaction.operationDescription
+ ].join(':'),
+ account: { id: account.id },
+ fee: 0,
+ invoice: hasInvoice
+ ? {
+ sum: transactionAmount,
+ instrument: transactionCurrency
+ }
+ : null,
+ sum: operationCurrency === account.instrument
+ ? operationAmount
+ : (transactionCurrency === account.instrument ? transactionAmount : null)
+ }
+ ],
+ merchant: null
+ }
+}
+
+export const convertCardStatementOperation = (fetchTransaction: FetchCardStatementOperation, account: Account): Transaction => {
+ const sign = Number(fetchTransaction.operationSign)
+ const operationSign = Number.isFinite(sign) && sign !== 0 ? sign : 1
+ const operationCurrency = ensureCurrency(fetchTransaction.operationCurrency) ?? account.instrument
+ const transactionCurrency = ensureCurrency(fetchTransaction.transactionCurrency) ?? operationCurrency
+ const operationAmount = fetchTransaction.operationAmount * operationSign
+ const transactionAmount = fetchTransaction.transactionAmount * operationSign
+ const hasInvoice = transactionCurrency !== account.instrument
+
+ return {
+ hold: null,
+ date: new Date(fetchTransaction.transactionDate > 0 ? fetchTransaction.transactionDate : fetchTransaction.operationDate),
+ comment: fetchTransaction.operationName,
+ movements: [
+ {
+ id: [
+ account.id,
+ fetchTransaction.operationDate,
+ fetchTransaction.operationCode,
+ fetchTransaction.transactionAmount,
+ fetchTransaction.operationAmount
+ ].join(':'),
+ account: { id: account.id },
+ fee: 0,
+ invoice: hasInvoice
+ ? {
+ sum: transactionAmount,
+ instrument: transactionCurrency
+ }
+ : null,
+ sum: operationCurrency === account.instrument
+ ? operationAmount
+ : (transactionCurrency === account.instrument ? transactionAmount : null)
+ }
+ ],
+ merchant: null
+ }
+}
diff --git a/src/plugins/vtb-by/fetchApi.ts b/src/plugins/vtb-by/fetchApi.ts
new file mode 100644
index 000000000..dd7709499
--- /dev/null
+++ b/src/plugins/vtb-by/fetchApi.ts
@@ -0,0 +1,135 @@
+import { fetchJson } from '../../common/network'
+import { RetryError, retry, toNodeCallbackArguments } from '../../common/retry'
+import { TemporaryUnavailableError } from '../../errors'
+import type * as T from './types/fetch'
+import { BASE_API_URL, CLIENT_KIND, LOGIN_DEVICE_INFO } from './models'
+
+const makeUrl = (path: string): string => `${BASE_API_URL}${path}`
+const NETWORK_ERROR_PATTERN = /\[NER]|\[NTI]|ECONNRESET|ETIMEDOUT|socket hang up/i
+const MAX_FETCH_ATTEMPTS = 3
+const FETCH_RETRY_DELAY_MS = 1500
+type FetchJsonResult = Awaited>
+type FetchAttemptResult = [Error | null, FetchJsonResult | null]
+
+const isFetchAttemptResult = (value: unknown): value is FetchAttemptResult => {
+ if (!Array.isArray(value) || value.length !== 2) return false
+
+ const [error, response] = value
+
+ if (!(error == null || error instanceof Error)) return false
+ if (response == null) return true
+ if (typeof response !== 'object') return false
+
+ return 'status' in response && 'body' in response
+}
+
+const fetchApi = async (path: string, body: unknown, sessionToken?: string): Promise => {
+ const headers: Record = {
+ Accept: 'application/json;charset=utf-8',
+ 'Content-Type': 'application/json;charset=UTF-8',
+ clientKind: CLIENT_KIND
+ }
+
+ if (sessionToken != null) {
+ headers.Authorization = sessionToken
+ headers.session_token = sessionToken
+ }
+
+ let result: FetchAttemptResult
+
+ try {
+ const retryResult = await retry({
+ getter: toNodeCallbackArguments(async () => await fetchJson(makeUrl(path), {
+ method: 'POST',
+ headers,
+ body,
+ sanitizeRequestLog: {
+ headers: {
+ Authorization: true,
+ session_token: true
+ },
+ body: {
+ login: true,
+ password: true
+ }
+ },
+ sanitizeResponseLog: {
+ body: {
+ sessionToken: true
+ }
+ }
+ })),
+ predicate: (value) => isFetchAttemptResult(value) && value[0] == null && value[1] != null && value[1].status < 500,
+ maxAttempts: MAX_FETCH_ATTEMPTS,
+ delayMs: FETCH_RETRY_DELAY_MS
+ })
+
+ if (!isFetchAttemptResult(retryResult)) {
+ throw new TemporaryUnavailableError()
+ }
+
+ result = retryResult
+ } catch (error) {
+ if (
+ error instanceof RetryError ||
+ (error instanceof Error && NETWORK_ERROR_PATTERN.test(error.message))
+ ) {
+ throw new TemporaryUnavailableError()
+ }
+
+ throw error
+ }
+
+ const [networkError, response] = result
+
+ if (
+ networkError != null ||
+ response == null
+ ) {
+ throw new TemporaryUnavailableError()
+ }
+
+ const { status, body: responseBody } = response
+
+ if (status !== 200) {
+ throw new TemporaryUnavailableError()
+ }
+
+ return responseBody
+}
+
+export const fetchLogin = async ({ login, password }: T.AuthenticateInput): Promise => {
+ return await fetchApi('/services/v2/session/login', {
+ ...LOGIN_DEVICE_INFO,
+ login,
+ password
+ }) as T.AuthenticateOutput
+}
+
+export const fetchAccountsOverview = async ({ sessionToken }: { sessionToken: string }): Promise => {
+ return await fetchApi('/services/v2/products/getUserAccountsOverview', {
+ concurrently: false,
+ cardAccount: {},
+ depositAccount: {},
+ currentAccount: {},
+ returnStatus: '1',
+ stateFilter: 'ACTIVE_AND_CLOSED',
+ corpoCardAccount: {},
+ additionCardAccount: {},
+ creditAccount: {}
+ }, sessionToken) as T.FetchAccountsOverviewOutput
+}
+
+export const fetchCardAccountFullStatement = async ({ sessionToken, internalAccountId }: T.FetchCardAccountFullStatementInput): Promise => {
+ return await fetchApi('/services/v2/products/getCardAccountFullStatement', {
+ internalAccountId
+ }, sessionToken) as T.FetchCardAccountFullStatementOutput
+}
+
+export const fetchMiniCardStatement = async ({ sessionToken, cardHash, from, till }: T.FetchMiniCardStatementInput): Promise => {
+ return await fetchApi('/services/v2/card/getMiniCardStatement', {
+ cardHash,
+ from,
+ till
+ }, sessionToken) as T.FetchMiniCardStatementOutput
+}
diff --git a/src/plugins/vtb-by/helpers.ts b/src/plugins/vtb-by/helpers.ts
new file mode 100644
index 000000000..e1caa63e8
--- /dev/null
+++ b/src/plugins/vtb-by/helpers.ts
@@ -0,0 +1,57 @@
+import codeToCurrencyLookup from '../../common/codeToCurrencyLookup'
+
+export const ensureCurrency = (codeOrName: string): string | undefined =>
+ isNaN(Number(codeOrName))
+ ? codeOrName
+ : codeToCurrencyLookup[codeOrName]
+
+export const isNonEmptyString = (value: unknown): value is string =>
+ typeof value === 'string' && value.length > 0
+
+export const getMaskedCardLastDigits = (cardNumberMasked: string): string =>
+ cardNumberMasked.replace(/\D/g, '').slice(-4)
+
+export const isDateInRange = (date: Date, fromDate: Date, toDate?: Date): boolean =>
+ date.getTime() >= fromDate.getTime() &&
+ (toDate == null || date.getTime() <= toDate.getTime())
+
+const MAX_MINI_STATEMENT_DAYS = 31
+
+const getStartOfDay = (date: Date): Date => {
+ const normalized = new Date(date)
+ normalized.setHours(0, 0, 0, 0)
+ return normalized
+}
+
+const getEndOfDay = (date: Date): Date => {
+ const normalized = new Date(date)
+ normalized.setHours(23, 59, 59, 999)
+ return normalized
+}
+
+export const getMiniStatementIntervals = (fromDate: Date, toDate?: Date): Array<{ from: number, till: number }> => {
+ const intervals: Array<{ from: number, till: number }> = []
+ const rangeEnd = getEndOfDay(toDate ?? new Date())
+ const earliestAvailableDate = getStartOfDay(new Date())
+ earliestAvailableDate.setDate(earliestAvailableDate.getDate() - MAX_MINI_STATEMENT_DAYS + 1)
+
+ let cursor = getStartOfDay(fromDate)
+
+ if (cursor.getTime() < earliestAvailableDate.getTime()) {
+ cursor = earliestAvailableDate
+ }
+
+ while (cursor.getTime() <= rangeEnd.getTime()) {
+ const intervalEnd = getEndOfDay(cursor)
+ intervalEnd.setDate(intervalEnd.getDate() + MAX_MINI_STATEMENT_DAYS - 1)
+
+ intervals.push({
+ from: cursor.getTime(),
+ till: Math.min(intervalEnd.getTime(), rangeEnd.getTime())
+ })
+
+ cursor = new Date(intervals[intervals.length - 1].till + 1)
+ }
+
+ return intervals
+}
diff --git a/src/plugins/vtb-by/index.ts b/src/plugins/vtb-by/index.ts
new file mode 100644
index 000000000..8c09ec064
--- /dev/null
+++ b/src/plugins/vtb-by/index.ts
@@ -0,0 +1,24 @@
+import type { ScrapeFunc, Transaction } from '../../types/zenmoney'
+import { authenticate, getAccounts, getTransactions } from './api'
+import type { PreferenceInput } from './types/base'
+
+export const scrape: ScrapeFunc = async ({ preferences, fromDate, toDate }) => {
+ const { login, password } = preferences
+
+ const { sessionToken } = await authenticate(login, password)
+
+ console.log('[SCRAPE:AUTHENTICATE] Success')
+
+ const accounts = await getAccounts({ sessionToken })
+
+ console.log('[SCRAPE:ACCOUNTS] Successfully fetched', accounts.length, 'account(s)')
+
+ const transactions: Transaction[] = []
+
+ for (const account of accounts) {
+ const accountTransactions = await getTransactions({ sessionToken, fromDate, toDate }, account)
+ transactions.push(...accountTransactions)
+ }
+
+ return { accounts, transactions }
+}
diff --git a/src/plugins/vtb-by/models.ts b/src/plugins/vtb-by/models.ts
new file mode 100644
index 000000000..7164fc1fd
--- /dev/null
+++ b/src/plugins/vtb-by/models.ts
@@ -0,0 +1,16 @@
+export const BASE_API_URL = 'https://online.vtb.by'
+
+export const CLIENT_KIND = '5'
+
+export const APPLICATION_ID = '3.5.2'
+
+export const LOGIN_DEVICE_INFO = {
+ applicID: APPLICATION_ID,
+ clientKind: CLIENT_KIND,
+ deviceUDID: 'ib',
+ browser: 'Google Chrome',
+ browserVersion: '0',
+ platform: 'Windows',
+ platformVersion: '10',
+ pushId: 'unknown'
+} as const
diff --git a/src/plugins/vtb-by/preferences.xml b/src/plugins/vtb-by/preferences.xml
new file mode 100644
index 000000000..af266cabe
--- /dev/null
+++ b/src/plugins/vtb-by/preferences.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
diff --git a/src/plugins/vtb-by/types/base.ts b/src/plugins/vtb-by/types/base.ts
new file mode 100644
index 000000000..f22931a06
--- /dev/null
+++ b/src/plugins/vtb-by/types/base.ts
@@ -0,0 +1,22 @@
+export interface PreferenceInput {
+ login: string
+ password: string
+}
+
+export interface FetchErrorInfo {
+ error: string
+ errorText: string
+ errorDescription?: string
+}
+
+export interface ResponseWithErrorInfo {
+ errorInfo: FetchErrorInfo
+}
+
+export interface FetchAccountMeta {
+ _meta: {
+ productKind: 'card' | 'current' | 'deposit'
+ statementInternalAccountId: string | null
+ statementCardHash: string | null
+ }
+}
diff --git a/src/plugins/vtb-by/types/fetch.ts b/src/plugins/vtb-by/types/fetch.ts
new file mode 100644
index 000000000..95704f311
--- /dev/null
+++ b/src/plugins/vtb-by/types/fetch.ts
@@ -0,0 +1,148 @@
+import type { ResponseWithErrorInfo } from './base'
+
+export interface AuthenticateInput {
+ login: string
+ password: string
+}
+
+export interface AuthenticateOutput extends ResponseWithErrorInfo {
+ sessionToken: string
+}
+
+export interface FetchCard {
+ cardNumberMasked: string
+ cardHash: string
+ cardStatus: string
+ cardStatusCode: number
+ owner: string
+ tariffName: string
+ balance: number
+ payment: string
+ accountId: string
+ stateSignature: string
+ cardProductId: number
+ cardId: number
+ salary: boolean
+ virtual: boolean
+ cardType?: {
+ name: string
+ paySystemName?: string
+ }
+}
+
+export interface FetchCardAccount {
+ internalAccountId: string
+ currency: string
+ openDate: number
+ accountNumber: string
+ cardAccountNumber: string
+ productCode: string
+ productName: string
+ contractId: string
+ interestRate: number
+ accountStatus: string
+ cards: FetchCard[]
+ ibanNum: string
+}
+
+export interface FetchCurrentAccount {
+ internalAccountId: string
+ currency: string
+ openDate: number
+ accountNumber: string
+ productCode: string
+ productName: string
+ balanceAmount: number
+ contractId: string
+ interestRate: number
+ accountStatus: string
+ ibanNum: string
+}
+
+export interface FetchDepositAccount {
+ internalAccountId: string
+ currency: string
+ openDate: number
+ endDate: number
+ accountNumber: string
+ productCode: string
+ productName: string
+ balanceAmount: number
+ contractId: string
+ interestRate: number
+ accountStatus: string
+ ibanNum: string
+ personalizedName?: string
+}
+
+export interface FetchAccountsOverviewOutput extends ResponseWithErrorInfo {
+ overviewResponse: {
+ cardAccount?: FetchCardAccount[]
+ currentAccount?: FetchCurrentAccount[]
+ depositAccount?: FetchDepositAccount[]
+ }
+}
+
+export interface FetchCardStatementOperation {
+ accountNumber: string
+ operationName: string
+ transactionDate: number
+ operationDate: number
+ transactionAmount: number
+ transactionCurrency: string
+ operationAmount: number
+ operationCurrency: string
+ operationSign: string
+ actionGroup: number
+ clientName?: string
+ operationClosingBalance: number
+ operationCode: number
+ merchantName?: string
+ mcc?: string
+}
+
+export interface FetchMiniCardStatementOperation {
+ operationDate: number
+ operationDescription: string
+ operationAmount: number
+ operationCurrency: string
+ operationPlace?: string
+ operationState: number
+ transactionAmount: number
+ transactionCurrency: string
+ mcc?: number
+ transactionAuthCode?: string
+}
+
+export interface FetchCardAccountFullStatementInput {
+ sessionToken: string
+ internalAccountId: string
+}
+
+export interface FetchMiniCardStatementInput {
+ sessionToken: string
+ cardHash: string
+ from: number
+ till: number
+}
+
+export interface FetchCardAccountFullStatementOutput extends ResponseWithErrorInfo {
+ operations: FetchCardStatementOperation[]
+ incomingBalance: number
+ closingBalance: number
+ debitAmount: number
+ creditAmount: number
+ accountNumber: string
+ accountName: string
+ accountCurrency: string
+ ibanNumber: string
+ incomeOverLimit: number
+ outcomeOverLimit: number
+ incomeForPeriod: number
+ outcomeForPeriod: number
+ lastActionDate: number
+}
+
+export interface FetchMiniCardStatementOutput extends ResponseWithErrorInfo {
+ statement: FetchMiniCardStatementOperation[]
+}