Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/plugins/vtb-by/ZenmoneyManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<provider>
<id>vtb-by</id>
<company>0</company>
<version>1.0</version>
<build>1</build>
<modular>true</modular>
<files>
<js>index.js</js>
<preferences>preferences.xml</preferences>
</files>
<codeRequired>false</codeRequired>
</provider>
Original file line number Diff line number Diff line change
@@ -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'
}
})
})
})
Original file line number Diff line number Diff line change
@@ -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
})
})
})
Original file line number Diff line number Diff line change
@@ -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
}
})
})
})
Original file line number Diff line number Diff line change
@@ -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
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { shouldSyncCardAccount } from '../../converters'
import type { FetchCardAccount } from '../../types/fetch'

const makeAccount = (overrides: Partial<FetchCardAccount> = {}): 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)
})
})
87 changes: 87 additions & 0 deletions src/plugins/vtb-by/api.ts
Original file line number Diff line number Diff line change
@@ -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<Array<Account & FetchAccountMeta>> => {
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<Transaction[]> => {
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<string>()

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
})
}
Loading
Loading