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[] +}