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
14 changes: 14 additions & 0 deletions src/plugins/altyn/ZenmoneyManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<provider>
<id>altyn</id>
<company>0</company>
<description>Синхронизация баланса и истории операций Алтын (lk.altyn.one) по токену.</description>
<version>1.0</version>
<build>1</build>
<modular>true</modular>
<codeRequired>false</codeRequired>
<files>
<js>index.js</js>
<preferences>preferences.xml</preferences>
</files>
</provider>
91 changes: 91 additions & 0 deletions src/plugins/altyn/__tests__/converters/accounts/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { AltynAccount } from '../../../models'
import { convertAccounts } from '../../../converters'

describe('convertAccounts', () => {
it('should convert ruble account', () => {
const accounts: AltynAccount[] = [
{
account_number: 'A-100000000001',
bank_name: null,
bic: null,
bank_inn: null,
bank_kpp: null,
cor_account: null,
currency: 'RUB',
balance: '12345.67'
}
]
expect(convertAccounts(accounts)).toEqual([
{
account: {
id: 'A-100000000001',
type: 'checking',
title: 'Алтын A-100000000001',
instrument: 'RUB',
balance: 12345.67,
syncIds: ['A-100000000001']
},
accountNumber: 'A-100000000001'
}
])
})

it('should convert account with integer balance', () => {
const accounts: AltynAccount[] = [
{
account_number: 'A-200000000002',
bank_name: null,
bic: null,
bank_inn: null,
bank_kpp: null,
cor_account: null,
currency: 'USD',
balance: '0'
}
]
expect(convertAccounts(accounts)).toEqual([
{
account: {
id: 'A-200000000002',
type: 'checking',
title: 'Алтын A-200000000002',
instrument: 'USD',
balance: 0,
syncIds: ['A-200000000002']
},
accountNumber: 'A-200000000002'
}
])
})

it('should convert multiple accounts', () => {
const accounts: AltynAccount[] = [
{
account_number: 'A-300000000003',
bank_name: null,
bic: null,
bank_inn: null,
bank_kpp: null,
cor_account: null,
currency: 'RUB',
balance: '100.50'
},
{
account_number: 'A-400000000004',
bank_name: null,
bic: null,
bank_inn: null,
bank_kpp: null,
cor_account: null,
currency: 'EUR',
balance: '200'
}
]
const result = convertAccounts(accounts)
expect(result).toHaveLength(2)
expect(result[0].account.instrument).toBe('RUB')
expect(result[1].account.instrument).toBe('EUR')
expect(result[0].account.balance).toBe(100.50)
expect(result[1].account.balance).toBe(200)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { AccountOrCard } from '../../../types/zenmoney'
import { AltynTransaction } from '../../../models'
import { convertTransaction } from '../../../converters'

const account: AccountOrCard = {
id: 'A-100000000001',
type: 'checking',
title: 'Алтын A-100000000001',
instrument: 'RUB',
balance: 12345.67,
syncIds: ['A-100000000001']
}

describe('convertTransaction', () => {
it('should convert withdraw (расход)', () => {
const apiTx: AltynTransaction = {
token: 'aaaa1111-bb22-cc33-dd44-eeeeeeeeeeee',
amount: '500.25',
currency: 'RUB',
created_at: '2026-07-05T10:30:33.000000Z',
type: 'withdraw',
status: 2,
label: 'Магазин',
details: 'Карта'
}
expect(convertTransaction(apiTx, account)).toEqual({
hold: null,
date: new Date('2026-07-05T10:30:33.000000Z'),
movements: [{
id: 'aaaa1111-bb22-cc33-dd44-eeeeeeeeeeee',
account: { id: 'A-100000000001' },
invoice: null,
sum: -500.25,
fee: 0
}],
merchant: { fullTitle: 'Магазин', mcc: null, location: null },
comment: null
})
})

it('should convert deposit (приход)', () => {
const apiTx: AltynTransaction = {
token: 'bbbb2222-cc33-dd44-ee55-ffffffffffff',
amount: '1000',
currency: 'RUB',
created_at: '2026-07-04T14:40:34.000000Z',
type: 'deposit',
status: 2,
label: 'Перевод',
details: 'Пополнение'
}
const result = convertTransaction(apiTx, account)
expect(result?.movements[0].sum).toBe(1000)
expect(result?.merchant?.fullTitle).toBe('Перевод')
})

it('should use label as merchant title (приоритет над details)', () => {
const apiTx: AltynTransaction = {
token: 'cccc3333-dd44-ee55-ff66-000000000001',
amount: '534.02',
currency: 'RUB',
created_at: '2026-07-04T12:55:38.000000Z',
type: 'deposit',
status: 2,
label: 'Бонус',
details: 'программа лояльности'
}
const result = convertTransaction(apiTx, account)
expect(result?.merchant?.fullTitle).toBe('Бонус')
})

it('should fall back to details when label is null', () => {
const apiTx: AltynTransaction = {
token: 'dddd4444-ee55-ff66-0077-000000000002',
amount: '50',
currency: 'RUB',
created_at: '2026-07-01T06:00:04.000000Z',
type: 'withdraw',
status: 2,
label: null,
details: 'Карта'
}
const result = convertTransaction(apiTx, account)
expect(result?.merchant?.fullTitle).toBe('Карта')
})

it('should return null merchant when both label and details are null', () => {
const apiTx: AltynTransaction = {
token: 'eeee5555-ff66-0077-1188-000000000003',
amount: '100',
currency: 'RUB',
created_at: '2026-07-01T06:00:04.000000Z',
type: 'withdraw',
status: 2,
label: null,
details: null
}
const result = convertTransaction(apiTx, account)
expect(result?.merchant).toBeNull()
})

it('should skip transactions with status !== 2', () => {
const apiTx: AltynTransaction = {
token: 'ffff6666-0077-1188-2299-000000000004',
amount: '1838',
currency: 'RUB',
created_at: '2026-04-03T09:13:36.000000Z',
type: 'withdraw',
status: 6,
label: 'Отменённый платёж',
details: 'Карта'
}
expect(convertTransaction(apiTx, account)).toBeNull()
})

it('should handle integer amount', () => {
const apiTx: AltynTransaction = {
token: '1111aaaa-2222-bbbb-3333-cccccccccccc',
amount: '999',
currency: 'RUB',
created_at: '2026-07-04T12:57:28.000000Z',
type: 'withdraw',
status: 2,
label: 'Услуга',
details: 'Карта'
}
const result = convertTransaction(apiTx, account)
expect(result?.movements[0].sum).toBe(-999)
})

it('should set correct movement id and account ref', () => {
const apiTx: AltynTransaction = {
token: '2222bbbb-3333-cccc-4444-dddddddddddd',
amount: '50',
currency: 'RUB',
created_at: '2026-07-01T06:00:04.000000Z',
type: 'withdraw',
status: 2,
label: 'Подписка',
details: 'Карта'
}
const result = convertTransaction(apiTx, account)
expect(result?.movements[0].id).toBe('2222bbbb-3333-cccc-4444-dddddddddddd')
expect(result?.movements[0].account).toEqual({ id: 'A-100000000001' })
})
})
97 changes: 97 additions & 0 deletions src/plugins/altyn/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { fetch, fetchJson, FetchResponse } from '../../common/network'
import { getString } from '../../types/get'
import { InvalidLoginOrPasswordError, TemporaryError } from '../../errors'
import { AltynAccount, AltynTransaction, Preferences } from './models'
import { fetchAccounts, fetchTransactions, LK_BASE } from './fetchApi'
import * as qs from 'querystring-browser'

type CookieMap = Map<string, string>

function parseSetCookie (setCookie: unknown): Array<[string, string]> {
if (setCookie == null) return []
const list = Array.isArray(setCookie) ? setCookie : [setCookie]
const result: Array<[string, string]> = []
for (const raw of list) {
if (typeof raw !== 'string') continue
const parts = raw.split(/,\s*(?=[^;]+?=)/)
for (const cookie of parts) {
const [pair] = cookie.split(';')
const eq = pair.indexOf('=')
if (eq > 0) {
result.push([pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()])
}
}
}
return result
}

function updateCookies (cookies: CookieMap, response: FetchResponse): void {
const headers = response.headers as Record<string, unknown>
const setCookie = headers.setCookie ?? headers['set-cookie']
for (const [name, value] of parseSetCookie(setCookie)) {
cookies.set(name, value)
}
}

function cookieHeader (cookies: CookieMap): Record<string, string> {
if (cookies.size === 0) return {}
const value = [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ')
return { Cookie: value }
}

// Авторизация через NextAuth: подтверждение токена PIN-кодом.
// Подтверждённая цепочка:
// 1. GET /api/auth/csrf → csrfToken + csrf-cookie
// 2. POST /api/auth/callback/credentials (otp=PIN, token=token, csrf-cookie)
// → ставит __Secure-next-auth.session-token, сервер помечает токен верифицированным
// api.lk.altyn.one после этого принимает Bearer-токен.
export async function authorize (preferences: Preferences): Promise<void> {
const cookies: CookieMap = new Map()

// 1. Получаем csrfToken + csrf-cookie
const csrfResponse = await fetchJson(`${LK_BASE}/api/auth/csrf`, {
headers: { Accept: 'application/json', ...cookieHeader(cookies) }
})
updateCookies(cookies, csrfResponse)
const csrfToken = getString(csrfResponse.body, 'csrfToken')

// 2. Подтверждаем сессию PIN-кодом.
// ВАЖНО: шлём csrf-cookie обратно — без него сервер вернёт {url:".../signin?csrf=true"}
const body = qs.stringify({
otp: preferences.pin,
token: preferences.token,
isPinEnabled: 'true',
redirect: 'false',
csrfToken,
callbackUrl: `${LK_BASE}/auth/pin`,
json: 'true'
})
const callbackResponse = await fetch(`${LK_BASE}/api/auth/callback/credentials`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
Accept: '*/*',
...cookieHeader(cookies)
},
body
})
updateCookies(cookies, callbackResponse)
if (callbackResponse.status === 401 || callbackResponse.status === 403) {
throw new InvalidLoginOrPasswordError('Неверный PIN-код или токен Алтын')
}
if (callbackResponse.status >= 400) {
throw new TemporaryError(`Алтын: не удалось подтвердить сессию (status ${callbackResponse.status})`)
}
}

export async function fetchAllAccounts (preferences: Preferences): Promise<AltynAccount[]> {
return await fetchAccounts(preferences)
}

export async function fetchAllTransactions (
preferences: Preferences,
fromDate: Date,
toDate: Date
): Promise<AltynTransaction[]> {
return await fetchTransactions(preferences, fromDate, toDate)
}
44 changes: 44 additions & 0 deletions src/plugins/altyn/converters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AccountOrCard, AccountType, Transaction } from '../../types/zenmoney'
import { AltynAccount, AltynTransaction, ConvertResult } from './models'

// Конвертация счетов Алтын в домен zenmoney.
// Каждый счёт → AccountType.checking, валюта и баланс берутся из API напрямую.
export function convertAccounts (apiAccounts: AltynAccount[]): ConvertResult[] {
return apiAccounts.map((apiAccount) => {
const account: AccountOrCard = {
id: apiAccount.account_number,
type: AccountType.checking,
title: 'Алтын ' + apiAccount.account_number,
instrument: apiAccount.currency,
balance: Number(apiAccount.balance),
syncIds: [apiAccount.account_number]
}
return { account, accountNumber: apiAccount.account_number }
})
}

// Конвертация операции Алтын в домен zenmoney.
// Импортируем только успешные операции (status === 2); прочие пропускаем.
// Знак суммы определяется типом: deposit → приход (+), withdraw → расход (−).
export function convertTransaction (apiTx: AltynTransaction, account: AccountOrCard): Transaction | null {
if (apiTx.status !== 2) {
return null
}
const isIncome = apiTx.type === 'deposit'
const sum = (isIncome ? 1 : -1) * Number(apiTx.amount)
const merchantTitle = apiTx.label ?? apiTx.details
return {
hold: null,
date: new Date(apiTx.created_at),
movements: [{
id: apiTx.token,
account: { id: account.id },
// Операция приходит в валюте счёта — invoice не нужен
invoice: null,
sum,
fee: 0
}],
merchant: merchantTitle !== null ? { fullTitle: merchantTitle, mcc: null, location: null } : null,
comment: null
}
}
Loading