diff --git a/src/plugins/postal-savings-rs/__tests__/converters/transactions/cardTransactions.test.ts b/src/plugins/postal-savings-rs/__tests__/converters/transactions/cardTransactions.test.ts new file mode 100644 index 00000000..a146251d --- /dev/null +++ b/src/plugins/postal-savings-rs/__tests__/converters/transactions/cardTransactions.test.ts @@ -0,0 +1,130 @@ +import { convertCardTransactions } from '../../../converters' + +const input = ` + + 05.04.2024 + -3.282,63 + RSD + 09.04.2024 + Wolt doo + 4870........1234 + + + 06.04.2024 + -370,00 + RSD + 09.04.2024 + LP NOVI CAJ VLADIMIR NOVI + 4870........1234 + + + 07.04.2024 + 500,00 + EUR + 10.04.2024 + REFUND U INOSTRANSTVU + 4870........1234 + + + 08.04.2024 + -210,00 + RSD + 10.04.2024 + TISHLER TOBACCO AND COFFENOVI + 4870........1234 + + + 12.04.2024 + 20,00 + USD + + CHATGPT SUBSCRIPTION +1415 + 4870........1234 + + + 13.04.2024 + 10.000,00 + RSD + + ATMBPS KAFE RANDEVU NOVI + 4870........1234 + + + 13.04.2024 + 250,00 + EUR + + REFUND U INOSTRANSTVU + 4870........1234 + +` + +describe('convertCardTransactions', () => { + it('converts card transactions', () => { + expect(convertCardTransactions(input)).toEqual([ + { + date: new Date('2024-04-13T00:00:00.000'), + authorizationDate: null, + amount: { + sum: 250.00, + instrument: 'EUR' + }, + merchant: 'REFUND U INOSTRANSTVU' + }, + { + date: new Date('2024-04-13T00:00:00.000'), + authorizationDate: null, + amount: { + sum: -10_000.00, + instrument: 'RSD' + }, + merchant: 'ATMBPS KAFE RANDEVU' + }, + { + date: new Date('2024-04-12T00:00:00.000'), + authorizationDate: null, + amount: { + sum: -20.00, + instrument: 'USD' + }, + merchant: 'CHATGPT SUBSCRIPTION' + }, + { + date: new Date('2024-04-08T00:00:00.000'), + authorizationDate: new Date('2024-04-10T00:00:00.000'), + amount: { + sum: -210.00, + instrument: 'RSD' + }, + merchant: 'TISHLER TOBACCO AND COFFE' + }, + { + date: new Date('2024-04-07T00:00:00.000'), + authorizationDate: new Date('2024-04-10T00:00:00.000'), + amount: { + sum: 500.00, + instrument: 'EUR' + }, + merchant: 'REFUND U INOSTRANSTVU' + }, + { + date: new Date('2024-04-06T00:00:00.000'), + authorizationDate: new Date('2024-04-09T00:00:00.000'), + amount: { + sum: -370.00, + instrument: 'RSD' + }, + merchant: 'LP NOVI CAJ VLADIMIR' + }, + { + date: new Date('2024-04-05T00:00:00.000'), + authorizationDate: new Date('2024-04-09T00:00:00.000'), + amount: { + sum: -3282.63, + instrument: 'RSD' + }, + merchant: 'Wolt doo' + } + ]) + }) +}) diff --git a/src/plugins/postal-savings-rs/__tests__/converters/transactions/exchangeRates.test.ts b/src/plugins/postal-savings-rs/__tests__/converters/transactions/exchangeRates.test.ts new file mode 100644 index 00000000..b9c65285 --- /dev/null +++ b/src/plugins/postal-savings-rs/__tests__/converters/transactions/exchangeRates.test.ts @@ -0,0 +1,158 @@ +import { convertExchangeRates } from '../../../converters' + +const input = ` + + E- BANKING EXCHANGE RATE LIST NO.: 66

APPLICABLE AS AT: 05.04.2024. + + + Currency code + Currency designation + Country + Unit + For foreign exchange + For foreign cash + + Buying exchange rate +Middle exchange rate + Selling exchange rate + Buying exchange rate + Selling exchange rate + + + +978

+EUR

+ E M U

+ 1

+ 116.7883

+ 117.1397

+ 117.4911

+ 116.7883

+ 117.6083

+ + + +036

+AUD

+ AUSTRALIJA

+ 1

+ 70.0520

+ 71.1188

+ 72.1856

+ 70.0520

+ 72.1856

+ + + +124

+CAD

+ KANADA

+ 1

+ 78.5289

+ 79.7248

+ 80.9207

+ 78.5289

+ 80.9207

+ + + +208

+DKK

+ DANSKA

+ 1

+ 15.4678

+ 15.7034

+ 15.9390

+ 15.4678

+ 15.9390

+ + + +578

+NOK

+ NORVESKA

+ 1

+ 9.9009

+ 10.0517

+ 10.2025

+ 9.9009

+ 10.2025

+ + + +643

+RUB

+ RUSIJA

+ 1

+ 1.1140

+ 1.1726

+ 1.2312

+ 0.0000

+ 0.0000

+ + + +752

+SEK

+ SVEDSKA

+ 1

+ 9.9941

+ 10.1463

+ 10.2985

+ 9.9941

+ 10.2985

+ + + +756

+CHF

+ SVAJCARSKA

+ 1

+ 118.0506

+ 119.8483

+ 121.6460

+ 118.0506

+ 121.6460

+ + + +826

+GBP

+ VEL. BRITANIJA

+ 1

+ 134.5570

+ 136.6061

+ 138.6552

+ 134.5570

+ 138.6552

+ + + +840

+USD

+ S A D

+ 1

+ 106.5299

+ 108.1522

+ 109.7745

+ 106.5299

+ 109.7745

+ +` + +describe('convertExchangeRates', () => { + it('converts exchange rates', () => { + expect(convertExchangeRates(input)).toEqual(new Map([ + ['EUR', 117.1397], + ['AUD', 71.1188], + ['CAD', 79.7248], + ['DKK', 15.7034], + ['NOK', 10.0517], + ['RUB', 1.1726], + ['SEK', 10.1463], + ['CHF', 119.8483], + ['GBP', 136.6061], + ['USD', 108.1522] + ])) + }) +}) diff --git a/src/plugins/postal-savings-rs/__tests__/converters/transactions/payment.test.ts b/src/plugins/postal-savings-rs/__tests__/converters/transactions/payment.test.ts index a511e204..6ca5d911 100644 --- a/src/plugins/postal-savings-rs/__tests__/converters/transactions/payment.test.ts +++ b/src/plugins/postal-savings-rs/__tests__/converters/transactions/payment.test.ts @@ -2,10 +2,9 @@ import { convertTransactions } from '../../../converters' describe('payment', () => { it('payment', () => { - expect(convertTransactions({ - id: 123456789, - type: 5 - }, '\n' + + expect(convertTransactions( + '1234567895', + '\n' + '05.12.2023\n' + 'ISPLATA VISA NICEFOODS\n' + 'EUR 978\n' + diff --git a/src/plugins/postal-savings-rs/api.ts b/src/plugins/postal-savings-rs/api.ts new file mode 100644 index 00000000..86909d90 --- /dev/null +++ b/src/plugins/postal-savings-rs/api.ts @@ -0,0 +1,116 @@ +import { accountDetailsToId, convertAccount, convertCardTransactions, convertExchangeRates, convertTransactions } from './converters' +import { AccountDetails, ExchangeRatesMap, PSAccount } from './models' +import { fetchAccountData, fetchCardTransactions, fetchExchangeRates } from './fetchApi' +import { Amount, Transaction } from '../../types/zenmoney' +import moment from 'moment' + +function accountCardKey (accountId: string): string { + return `account/${accountId}/card` +} + +export async function fetchAccount (accountDetails: AccountDetails): Promise { + const accountId = accountDetailsToId(accountDetails) + let cardNumber = ZenMoney.getData(accountCardKey(accountId)) as string | null | undefined + + if (cardNumber === undefined) { + cardNumber = await readCardNumber(`Enter card number for account ${accountDetails.id} (optional):`) + ZenMoney.setData(accountCardKey(accountId), cardNumber) + ZenMoney.saveData() + } + + const rawData = await fetchAccountData(accountDetails) + const account = convertAccount(accountDetails, rawData) + if (cardNumber !== null) { + account.syncIds.push(cardNumber) + } + + return { + ...account, + cardNumber, + rawData + } +} + +async function readCardNumber (prompt: string): Promise { + let cardNumber: string | undefined + while (cardNumber === undefined) { + const input = (await ZenMoney.readLine(prompt, { inputType: 'number' }) ?? '').replace(/\D/g, '') + if (input.length === 16) { + cardNumber = input + } else { + await ZenMoney.alert('Card number should contain exactly 16 digits. You can leave the field empty if you don\'t have a card') + } + } + return cardNumber === '' || cardNumber === '0' ? null : cardNumber +} + +export async function fetchTransactions (account: PSAccount, fromDate: Date, toDate: Date): Promise { + const accountTransactions = convertTransactions(account.id, account.rawData) + + if (account.cardNumber !== null) { + const cardTransactions = convertCardTransactions(await fetchCardTransactions(account.cardNumber, fromDate, toDate)) + + for (const transaction of cardTransactions) { + // Calculate amount in account currency to be able to match account and card transactions + const accountCurrencyAmount = await convertAmount( + transaction.authorizationDate ?? transaction.date, + transaction.amount, + account.instrument + ) + transaction.accountSum = accountCurrencyAmount?.sum + } + // TODO: Remove after debug + console.log(cardTransactions) + } + + return accountTransactions.filter(transaction => transaction.date >= fromDate && transaction.date <= toDate) +} + +async function convertAmount (date: Date, amount: Amount, targetCurrency: string): Promise { + if (amount.instrument === targetCurrency) { + return amount + } + + const sourceRate = await getExchangeRate(date, amount.instrument) + if (sourceRate === undefined) { + return undefined + } + const targetRate = await getExchangeRate(date, targetCurrency) + if (targetRate === undefined) { + return undefined + } + + return { + sum: amount.sum * sourceRate / targetRate, + instrument: targetCurrency + } +} + +async function getExchangeRate (date: Date, currency: string): Promise { + if (currency === 'RSD') { + return 1 + } + + const exchangeRates = await getExchangeRates(date) + return exchangeRates.get(currency) +} + +const exchangeRatesByDate = new Map() + +async function getExchangeRates (date: Date | null): Promise { + const dateKey = date !== null ? moment(date).format('DD.MM.YYYY') : 'latest' + let exchangeRates = exchangeRatesByDate.get(dateKey) + if (exchangeRates === undefined) { + const rawData = await fetchExchangeRates(date) + exchangeRates = convertExchangeRates(rawData) + + // The bank can just not update exchange rates at holidays. + // Use the latest known exchange rate in this case. + if (exchangeRates.size === 0) { + exchangeRates = await getExchangeRates(null) + } + exchangeRatesByDate.set(dateKey, exchangeRates) + } + + return exchangeRates +} diff --git a/src/plugins/postal-savings-rs/converters.ts b/src/plugins/postal-savings-rs/converters.ts index a1a2a43d..06624577 100644 --- a/src/plugins/postal-savings-rs/converters.ts +++ b/src/plugins/postal-savings-rs/converters.ts @@ -1,8 +1,8 @@ -import { AccountType, Transaction, Account } from '../../types/zenmoney' -import { AccountDetails } from './models' +import { AccountOrCard, AccountType, Transaction } from '../../types/zenmoney' +import { AccountDetails, CardTransaction, ExchangeRatesMap } from './models' import moment from 'moment' -function accountDetailsToId (account: AccountDetails): string { +export function accountDetailsToId (account: AccountDetails): string { return `${account.id}${account.type}` } @@ -10,6 +10,17 @@ function parseSum (s: string): number { return parseFloat(s.replace(' ', '').replace('.', '').replace(',', '.')) } +function parseDate (s: string): Date { + return moment(s, 'DD.MM.YYYY').toDate() +} + +function tableLineRegexp (columns: number, cellContentPattern?: string): RegExp { + cellContentPattern = cellContentPattern ?? '([^<]+)' + const cellPattern = `]*>${cellContentPattern}<\\/td>\\s+` + const regexStr = `]*>\\s+${cellPattern.repeat(columns)}<\\/tr>` + return new RegExp(regexStr, 'g') +} + function descriptionCleanup (s: string): string { const substrToRemove = ['ISPLATA VISA', 'UPLATA VISA'] @@ -24,7 +35,12 @@ function descriptionCleanup (s: string): string { return strWithoutGarbage.length > 0 ? strWithoutGarbage : decodedStr } -export function convertAccount (account: AccountDetails, data: string): Account { +// 'TRGOCENTAR DOO BEOGR' -> 'TRGOCENTAR DOO' +function stripLocationSuffix (line: string): string { + return line.slice(0, -5).trim() +} + +export function convertAccount (account: AccountDetails, data: string): AccountOrCard { const id = accountDetailsToId(account) const descriptionPattern = /([\w\s]+):<\/td>\s+\d+<\/td>/ @@ -44,22 +60,47 @@ export function convertAccount (account: AccountDetails, data: string): Account } } -export function convertTransactions (account: AccountDetails, data: string): Transaction[] { +export function convertCardTransactions (data: string): CardTransaction[] { + const transactions: CardTransaction[] = [] + + const matches = data.matchAll(tableLineRegexp(6)) + + for (const t of matches) { + const [, date, sum, currency, authorizationDate, desc] = t + + const transaction: CardTransaction = { + date: parseDate(date), + authorizationDate: (authorizationDate.trim() !== '') ? parseDate(authorizationDate) : null, + amount: { + // Amount sign is not true for unauthorized transaction so ignore it. + // We will restore the sign later according to a corresponding account transaction's sign + sum: Math.abs(parseSum(sum)), + instrument: currency + }, + merchant: descriptionCleanup(stripLocationSuffix(desc)) + } + + transactions.push(transaction) + } + + return transactions.reverse() +} + +export function convertTransactions (accountId: string, data: string): Transaction[] { const transactions: Transaction[] = [] - const regexp = /\s+]+>([\d.]+)<\/td>\s+]+>([^<]+)<\/td>\s+]+>(\w+)\s\d+<\/td>\s+]+>\+?([-\d,.]+)<\/td>/g - const matches = data.matchAll(regexp) + const matches = data.matchAll(tableLineRegexp(6)) for (const t of matches) { const [, date, desc, , sum] = t transactions.push({ hold: false, - date: moment(date, 'DD.MM.YYYY').toDate(), + date: parseDate(date), movements: [ { id: null, - account: { id: accountDetailsToId(account) }, + account: { id: accountId }, // TODO: parse from /kartizv.jsp invoice: null, sum: parseSum(sum), @@ -79,3 +120,18 @@ export function convertTransactions (account: AccountDetails, data: string): Tra return transactions.reverse() } + +export function convertExchangeRates (data: string): ExchangeRatesMap { + const exchangeRates: ExchangeRatesMap = new Map() + + const cellContentPattern = '(?:)?([^<]+)(?:

)?' + const matches = data.matchAll(tableLineRegexp(9, cellContentPattern)) + + for (const t of matches) { + // Currency code, Currency designation, Country, Unit, Buying rate, Middle rate, Selling rate, Buying rate (cash), Selling rate (cash) + const [, , currency, , , , middleRate] = t + exchangeRates.set(currency, parseFloat(middleRate.trim())) + } + + return exchangeRates +} diff --git a/src/plugins/postal-savings-rs/fetchApi.ts b/src/plugins/postal-savings-rs/fetchApi.ts index 4b7954c0..b0dd33cd 100644 --- a/src/plugins/postal-savings-rs/fetchApi.ts +++ b/src/plugins/postal-savings-rs/fetchApi.ts @@ -1,6 +1,8 @@ import { FetchResponse, fetch } from '../../common/network' +import { toAtLeastTwoDigitsString } from '../../common/stringUtils' import { InvalidLoginOrPasswordError } from '../../errors' import { AccountDetails, Preferences } from './models' +import moment from 'moment' const baseUrl = 'https://hb.posted.co.rs/posted/en/' @@ -104,3 +106,30 @@ export async function fetchAccountData (account: AccountDetails): Promise { + fromDate = fromDate.getFullYear() >= toDate.getFullYear() ? fromDate : new Date(toDate.getFullYear(), 0, 1) + + const fromDay = toAtLeastTwoDigitsString(fromDate.getDate()) + const fromMonth = toAtLeastTwoDigitsString(fromDate.getMonth() + 1) + const toDay = toAtLeastTwoDigitsString(toDate.getDate()) + const toMonth = toAtLeastTwoDigitsString(toDate.getMonth() + 1) + const year = toDate.getFullYear() + + const response = await fetchUrl('karttrn.jsp', { + method: 'POST', + body: `KOM=K3&H1=${cardNumber}&IRADIO=I2&oddan=${fromDay}&odmes=${fromMonth}&dodan=${toDay}&domes=${toMonth}&god=${year}` + }) + + return response.body as string +} + +export async function fetchExchangeRates (date: Date | null): Promise { + const formattedDate = date !== null ? moment(date).format('DD.MM.YYYY') : '' + const response = await fetchUrl('kursl.jsp', { + method: 'POST', + body: `DATUM=${formattedDate}&check1=on` + }) + + return response.body as string +} diff --git a/src/plugins/postal-savings-rs/index.ts b/src/plugins/postal-savings-rs/index.ts index f83ce369..d88ab1b7 100644 --- a/src/plugins/postal-savings-rs/index.ts +++ b/src/plugins/postal-savings-rs/index.ts @@ -1,7 +1,8 @@ import { ScrapeFunc, Transaction, Account } from '../../types/zenmoney' -import { fetchAllAccounts, fetchAuthorization, fetchAccountData } from './fetchApi' +import { fetchAllAccounts, fetchAuthorization } from './fetchApi' import { Preferences } from './models' -import { convertAccount, convertTransactions } from './converters' +import { accountDetailsToId } from './converters' +import { fetchAccount, fetchTransactions } from './api' export const scrape: ScrapeFunc = async ({ preferences, fromDate, toDate }) => { toDate = toDate ?? new Date() @@ -12,12 +13,14 @@ export const scrape: ScrapeFunc = async ({ preferences, fromDate, t const accounts: Account[] = [] const transactions: Transaction[] = [] - for (const account of fetchedAccounts) { - const rawAccountData = await fetchAccountData(account) - accounts.push(convertAccount(account, rawAccountData)) - const filteredTransactions = convertTransactions(account, rawAccountData) - .filter(transaction => transaction.date >= fromDate && (toDate == null || transaction.date <= toDate)) - transactions.push(...filteredTransactions) + for (const accountDetails of fetchedAccounts) { + if (ZenMoney.isAccountSkipped(accountDetailsToId(accountDetails))) { + continue + } + + const account = await fetchAccount(accountDetails) + accounts.push(account) + transactions.push(...await fetchTransactions(account, fromDate, toDate)) } return { diff --git a/src/plugins/postal-savings-rs/models.ts b/src/plugins/postal-savings-rs/models.ts index 3d5f6837..daff85a5 100644 --- a/src/plugins/postal-savings-rs/models.ts +++ b/src/plugins/postal-savings-rs/models.ts @@ -1,3 +1,5 @@ +import { AccountOrCard, Amount } from '../../types/zenmoney' + // Input preferences from schema in preferences.xml export interface Preferences { login: string @@ -15,3 +17,18 @@ export interface AccountDetails { id: number type: AccountType } + +export interface PSAccount extends AccountOrCard { + cardNumber: string | null + rawData: string +} + +export interface CardTransaction { + date: Date + authorizationDate: Date | null + amount: Amount + accountSum?: number + merchant: string +} + +export type ExchangeRatesMap = Map