From 5577bc34156765174d62ef7dcf9ad64402149753 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Thu, 28 May 2026 18:33:04 +0400 Subject: [PATCH 1/5] Add BI source integration and update rate source configs --- README.md | 2 +- .../vality/rateboss/client/bi/BiApiClient.kt | 38 +++++ .../dev/vality/rateboss/config/JobConfig.kt | 26 ++++ .../config/properties/RatesProperties.kt | 9 ++ .../rateboss/job/BiExchangeGrabberJob.kt | 37 +++++ .../job/BiExchangeGrabberMasterJob.kt | 18 +++ .../rateboss/job/constant/ExRateSources.kt | 1 + .../source/impl/BiExchangeRateSource.kt | 134 ++++++++++++++++++ src/main/resources/application.yml | 12 ++ .../dev/vality/rateboss/config/TestConfig.kt | 12 ++ .../rateboss/job/CbrExchangeGrabberJobTest.kt | 2 +- .../job/FixerExchangeGrabberJobTest.kt | 2 +- .../job/NbkrExchangeGrabberJobTest.kt | 2 +- .../job/NbkzExchangeGrabberJobTest.kt | 2 +- .../job/NbuzExchangeGrabberJobTest.kt | 2 +- .../source/BiExchangeRateSourceTest.kt | 98 +++++++++++++ 16 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt create mode 100644 src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberJob.kt create mode 100644 src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberMasterJob.kt create mode 100644 src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt create mode 100644 src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt diff --git a/README.md b/README.md index 85e82d6..5110852 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rate-boss periodically pulls exchange rates from external sources, stores them in the database, and optionally publishes rate events to Kafka. It also exposes API endpoints to read the latest or historical rates and to convert amounts by timestamp. -Supported sources today: Fixer, CBR, NBKZ. +Supported sources today: Fixer, CBR, BI, NBKZ. ## Service flow (actual) diff --git a/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt b/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt new file mode 100644 index 0000000..e84e0be --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt @@ -0,0 +1,38 @@ +package dev.vality.rateboss.client.bi + +import dev.vality.rateboss.config.properties.RatesProperties +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate +import org.springframework.web.client.exchange +import org.springframework.web.util.UriComponentsBuilder +import java.time.LocalDate + +@Component +class BiApiClient( + private val restTemplate: RestTemplate, + private val ratesProperties: RatesProperties, +) { + fun getExchangeRates( + currencySymbolCode: String, + startDate: LocalDate, + endDate: LocalDate, + ): String { + val url = buildUrl(currencySymbolCode, startDate, endDate) + return restTemplate.exchange(url, HttpMethod.GET, HttpEntity.EMPTY).body!! + } + + private fun buildUrl( + currencySymbolCode: String, + startDate: LocalDate, + endDate: LocalDate, + ): String = + UriComponentsBuilder + .fromUriString(ratesProperties.source.bi.rootUrl) + .queryParam("mts", currencySymbolCode) + .queryParam("startdate", startDate) + .queryParam("enddate", endDate) + .build() + .toUriString() +} diff --git a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt index 7eb4386..996eed8 100644 --- a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt +++ b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt @@ -1,6 +1,7 @@ package dev.vality.rateboss.config import dev.vality.rateboss.config.properties.RatesProperties +import dev.vality.rateboss.job.BiExchangeGrabberMasterJob import dev.vality.rateboss.job.CbrExchangeGrabberMasterJob import dev.vality.rateboss.job.FixerExchangeGrabberMasterJob import dev.vality.rateboss.job.NbazExchangeGrabberMasterJob @@ -47,6 +48,16 @@ class JobConfig { schedulerFactoryBean.triggerJob(JobKey(ratesProperties.cbrJob.jobKey)) } } + val biJobTriggerName = ratesProperties.biJob.jobTriggerName + if (biJobTriggerName.isNotEmpty()) { + schedulerFactoryBean.addJob(biExchangeRateGrabberMasterJob(), true, true) + if (!schedulerFactoryBean.checkExists(TriggerKey(ratesProperties.biJob.jobTriggerName))) { + schedulerFactoryBean.scheduleJob(biExchangeRateGrabberMasterTrigger()) + } + if (runOnStartup) { + schedulerFactoryBean.triggerJob(JobKey(ratesProperties.biJob.jobKey)) + } + } val nbkzJobTriggerName = ratesProperties.nbkzJob.jobTriggerName if (nbkzJobTriggerName.isNotEmpty()) { schedulerFactoryBean.addJob(nbkzExchangeRateGrabberMasterJob(), true, true) @@ -119,6 +130,21 @@ class JobConfig { .withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.cbrJob.jobCron)) .build() + fun biExchangeRateGrabberMasterJob(): JobDetailImpl { + val jobDetail = JobDetailImpl() + jobDetail.key = JobKey(ratesProperties.biJob.jobKey) + jobDetail.jobClass = BiExchangeGrabberMasterJob::class.java + return jobDetail + } + + fun biExchangeRateGrabberMasterTrigger(): CronTrigger = + TriggerBuilder + .newTrigger() + .forJob(biExchangeRateGrabberMasterJob()) + .withIdentity(ratesProperties.biJob.jobTriggerName) + .withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.biJob.jobCron)) + .build() + fun nbkzExchangeRateGrabberMasterJob(): JobDetailImpl { val jobDetail = JobDetailImpl() jobDetail.key = JobKey(ratesProperties.nbkzJob.jobKey) diff --git a/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt b/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt index 90a60d8..1f24ae9 100644 --- a/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt +++ b/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt @@ -9,6 +9,7 @@ import java.time.ZoneId data class RatesProperties( val fixerJob: JobDescription, val cbrJob: JobDescription, + val biJob: JobDescription, val nbkzJob: JobDescription, val nbkrJob: JobDescription, val nbuzJob: JobDescription, @@ -31,6 +32,7 @@ data class CurrencyProperties( data class RatesSourceProperties( val fixer: FixerProperties, val cbr: CbrProperties, + val bi: BiProperties, val nbkz: NbkzProperties, val nbkr: NbkrProperties, val nbuz: NbuzProperties, @@ -47,6 +49,13 @@ data class CbrProperties( val timeZone: ZoneId, ) +data class BiProperties( + val rootUrl: String, + val timeZone: ZoneId, + val lookbackDays: Long, + val targetCurrencies: List, +) + data class NbkzProperties( val rootUrl: String, val dateFormat: String, diff --git a/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberJob.kt b/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberJob.kt new file mode 100644 index 0000000..f9a27e6 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberJob.kt @@ -0,0 +1,37 @@ +package dev.vality.rateboss.job + +import dev.vality.rateboss.extensions.getApplicationContext +import dev.vality.rateboss.source.ExchangeRateSource +import dev.vality.rateboss.source.ExchangeRateSourceException +import dev.vality.rateboss.source.impl.BiExchangeRateSource +import dev.vality.rateboss.source.model.ExchangeRates +import mu.KotlinLogging +import org.quartz.JobExecutionContext +import org.springframework.context.ApplicationContext +import org.springframework.retry.support.RetryTemplate + +private val log = KotlinLogging.logger {} + +class BiExchangeGrabberJob : AbstractExchangeGrabberJob() { + override fun executeInternal(context: JobExecutionContext) { + val applicationContext = context.getApplicationContext() + val currencySymbolCode = context.jobDetail.jobDataMap["currencySymbolCode"] as String + val currencyExponent = context.jobDetail.jobDataMap["currencyExponent"] as Int + val exchangeRateSource = applicationContext.getBean(BiExchangeRateSource::class.java) + val sourceId = exchangeRateSource.getSourceId() + log.info { "Process BiExchangeGrabberJob for $sourceId" } + val exchangeRates = getExchangeRates(applicationContext, exchangeRateSource, currencySymbolCode) + saveExchangeRates(applicationContext, currencySymbolCode, currencyExponent, exchangeRates, sourceId) + } + + private fun getExchangeRates( + applicationContext: ApplicationContext, + exchangeRateSource: ExchangeRateSource, + currencySymbolCode: String, + ): ExchangeRates { + val retryTemplate = applicationContext.getBean(RetryTemplate::class.java) + return retryTemplate.execute { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + } +} diff --git a/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberMasterJob.kt b/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberMasterJob.kt new file mode 100644 index 0000000..aae6683 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberMasterJob.kt @@ -0,0 +1,18 @@ +package dev.vality.rateboss.job + +import dev.vality.rateboss.config.properties.RatesProperties +import dev.vality.rateboss.extensions.getApplicationContext +import org.quartz.JobExecutionContext +import org.quartz.Scheduler + +class BiExchangeGrabberMasterJob : AbstractExchangeGrabberMasterJob() { + override fun executeInternal(context: JobExecutionContext) { + val applicationContext = context.getApplicationContext() + val ratesProperties = applicationContext.getBean(RatesProperties::class.java) + val currencies = ratesProperties.biJob.currencies + val schedulerFactoryBean = applicationContext.getBean(Scheduler::class.java) + launchJob(currencies, schedulerFactoryBean, BiExchangeGrabberJob::class.java, getJobName()) + } + + override fun getJobName(): String = "biJob" +} diff --git a/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt b/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt index 0874393..0c4e845 100644 --- a/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt +++ b/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt @@ -3,6 +3,7 @@ package dev.vality.rateboss.job.constant object ExRateSources { const val FIXER = "fixer" const val CBR = "cbr" + const val BI = "bi" const val NBKZ = "nbkz" const val NBKR = "nbkr" const val NBUZ = "nbuz" diff --git a/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt b/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt new file mode 100644 index 0000000..62b1ee5 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt @@ -0,0 +1,134 @@ +package dev.vality.rateboss.source.impl + +import dev.vality.rateboss.client.bi.BiApiClient +import dev.vality.rateboss.config.properties.RatesProperties +import dev.vality.rateboss.job.constant.ExRateSources +import dev.vality.rateboss.source.ExchangeRateSource +import dev.vality.rateboss.source.ExchangeRateSourceException +import dev.vality.rateboss.source.model.ExchangeRates +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.w3c.dom.Element +import org.w3c.dom.Node +import java.io.ByteArrayInputStream +import java.math.BigDecimal +import java.math.MathContext +import java.nio.charset.StandardCharsets +import java.time.LocalDate +import java.time.ZoneOffset +import javax.xml.parsers.DocumentBuilderFactory + +private val log = KotlinLogging.logger {} + +@Component +class BiExchangeRateSource( + private val biApiClient: BiApiClient, + private val ratesProperties: RatesProperties, +) : ExchangeRateSource { + override fun getExchangeRate(currencySymbolCode: String): ExchangeRates { + val timeZone = ratesProperties.source.bi.timeZone + val date = LocalDate.now(timeZone) + val lookbackDays = ratesProperties.source.bi.lookbackDays + val targetCurrencies = ratesProperties.source.bi.targetCurrencies + val startDate = date.minusDays(lookbackDays) + log.info { "Trying to get exchange rates from bi for currency=$currencySymbolCode, date=$date" } + + val rates = mutableMapOf() + for (targetCurrency in targetCurrencies) { + val response = + try { + biApiClient.getExchangeRates( + currencySymbolCode = targetCurrency, + startDate = startDate, + endDate = date, + ) + } catch (e: Exception) { + throw ExchangeRateSourceException("Remote client exception", e) + } + + val parsedRate = + try { + parseRate(response, targetCurrency) + } catch (e: Exception) { + throw ExchangeRateSourceException("Failed to parse response from BiApi", e) + } + if (parsedRate != null) { + rates[targetCurrency] = parsedRate + } + } + + if (rates.isEmpty()) { + throw ExchangeRateSourceException("Unsuccessful response from BiApi for period $startDate..$date") + } + + val nextDayTimestamp = date.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC) + log.info { "Exchange rates from bi have been retrieved, date=$date, exchangeRates=$rates, targetTimestamp=$nextDayTimestamp" } + + return ExchangeRates( + rates = rates, + timestamp = nextDayTimestamp, + ) + } + + override fun getSourceId(): String = ExRateSources.BI + + private fun parseRate( + xmlContent: String, + targetCurrency: String, + ): BigDecimal? { + val document = + DocumentBuilderFactory + .newInstance() + .newDocumentBuilder() + .parse(ByteArrayInputStream(xmlContent.toByteArray(StandardCharsets.UTF_8))) + + val tables = document.getElementsByTagName("Table") + var idrPerTargetCurrency: BigDecimal? = null + + for (i in 0 until tables.length) { + val tableNode = tables.item(i) + if (tableNode.nodeType != Node.ELEMENT_NODE) { + continue + } + val table = tableNode as Element + val parsedValue = + extractDecimalByTags( + table, + listOf("kurs_tengah", "kursjual", "kurs_jual", "kursbeli", "kurs_beli"), + ) + if (parsedValue != null && parsedValue.compareTo(BigDecimal.ZERO) > 0) { + idrPerTargetCurrency = parsedValue + } + } + + val rate = idrPerTargetCurrency ?: return null + log.debug { "BI rate parsed for targetCurrency=$targetCurrency: idrPerTargetCurrency=$rate" } + return BigDecimal.ONE.divide(rate, MathContext.DECIMAL64) + } + + private fun extractDecimalByTags( + parent: Element, + tags: List, + ): BigDecimal? { + for (tag in tags) { + val value = + parent + .getElementsByTagName(tag) + .item(0) + ?.textContent + ?.normalizeDecimal() + ?.toBigDecimalOrNull() + if (value != null) { + return value + } + } + return null + } + + private fun String?.normalizeDecimal(): String = + this + ?.replace(" ", "") + ?.replace(",", ".") + ?.trim() + .orEmpty() +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 51c5f07..7301a67 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -90,6 +90,13 @@ rates: currencies: - symbolCode: "RUB" exponent: 2 + biJob: + jobCron: '0 0 0/1 * * ?' + jobKey: 'bi-exchange-rate-grabber-master-job' + jobTriggerName: 'bi-exchange-rate-grabber-master-job-trigger' + currencies: + - symbolCode: "IDR" + exponent: 2 nbkzJob: jobCron: '0 0 0/1 * * ?' jobKey: 'nbkz-exchange-rate-grabber-master-job' @@ -125,6 +132,11 @@ rates: cbr: rootUrl: https://www.cbr.ru/scripts/XML_daily.asp timeZone: Europe/Moscow + bi: + rootUrl: https://www.bi.go.id/biwebservice/wskursbi.asmx/getSubKursLokal3 + timeZone: Asia/Jakarta + lookbackDays: 7 + targetCurrencies: [ "USD", "EUR" ] nbkz: rootUrl: https://nationalbank.kz/rss/get_rates.cfm dateFormat: dd.MM.yyyy diff --git a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt index ff7490b..638da36 100644 --- a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt +++ b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt @@ -41,6 +41,12 @@ class TestConfig { "cbr-name", listOf(CurrencyProperties("RUB", 2)), ), + JobDescription( + "bi-cron", + "bi-key", + "bi-name", + listOf(CurrencyProperties("IDR", 2)), + ), JobDescription( "nbkz-cron", "nbkz-key", @@ -68,6 +74,12 @@ class TestConfig { RatesSourceProperties( FixerProperties("url", "key"), CbrProperties("https://www.cbr.ru/scripts/XML_daily.asp", ZoneId.of("Europe/Moscow")), + BiProperties( + "https://www.bi.go.id/biwebservice/wskursbi.asmx/getSubKursLokal3", + ZoneId.of("Asia/Jakarta"), + 7, + listOf("USD", "EUR"), + ), NbkzProperties("https://nationalbank.kz/rss/get_rates.cfm", "dd.MM.yyyy", ZoneId.of("Asia/Almaty")), NbkrProperties("https://www.nbkr.kg/XML/daily.xml", ZoneId.of("Asia/Bishkek")), NbuzProperties( diff --git a/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt index 11e9e97..7e4fe9b 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt @@ -51,7 +51,7 @@ class CbrExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) - scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt index 826113d..18f9e86 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt @@ -62,7 +62,7 @@ class FixerExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) - scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt index 4e22fd2..66a9113 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt @@ -51,7 +51,7 @@ class NbkrExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) - scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt index 36dd2a9..d775b94 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt @@ -51,7 +51,7 @@ class NbkzExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) - scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt index 4d48e92..d122ef8 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt @@ -51,7 +51,7 @@ class NbuzExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) - scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt b/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt new file mode 100644 index 0000000..3936921 --- /dev/null +++ b/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt @@ -0,0 +1,98 @@ +package dev.vality.rateboss.source + +import dev.vality.rateboss.client.bi.BiApiClient +import dev.vality.rateboss.config.TestConfig +import dev.vality.rateboss.source.impl.BiExchangeRateSource +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Import +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.web.client.ResourceAccessException +import java.math.BigDecimal + +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [BiApiClient::class, BiExchangeRateSource::class]) +@Import(TestConfig::class) +class BiExchangeRateSourceTest { + @Autowired + lateinit var exchangeRateSource: ExchangeRateSource + + @MockitoBean + lateinit var biApiClient: BiApiClient + + @Test + fun getFailedExchangeRate() { + val currencySymbolCode = "IDR" + whenever(biApiClient.getExchangeRates(any(), any(), any())).thenThrow(ResourceAccessException("Error")) + + val exception = + org.junit.jupiter.api.assertThrows { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + + assertEquals("Remote client exception", exception.message) + } + + @Test + fun getEmptyExchangeRate() { + val currencySymbolCode = "IDR" + whenever(biApiClient.getExchangeRates(any(), any(), any())).thenReturn("") + + val exception = + org.junit.jupiter.api.assertThrows { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + + assertTrue(exception.message!!.startsWith("Unsuccessful response from BiApi for period")) + } + + @Test + fun getSuccessExchangeRate() { + val currencySymbolCode = "IDR" + whenever(biApiClient.getExchangeRates(any(), any(), any())).thenReturn( + """ + + + 16400,00 +
+
+ """.trimIndent(), + ) + + val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) + + assertNotNull(exchangeRate) + assertTrue(exchangeRate.rates.isNotEmpty()) + assertTrue(exchangeRate.rates.containsKey("USD")) + assertEquals(BigDecimal("0.00006097560975609756"), exchangeRate.rates["USD"]) + } + + @Test + fun getSuccessExchangeRateWhenCurrentDayMissing() { + val currencySymbolCode = "IDR" + whenever(biApiClient.getExchangeRates(any(), any(), any())).thenReturn( + """ + + + +
+ + 16300,00 +
+
+ """.trimIndent(), + ) + + val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) + + assertEquals(BigDecimal("0.00006134969325153374"), exchangeRate.rates["USD"]) + } +} From 715aadb023dd4ee1622a1de3b194845584ef65f3 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Thu, 28 May 2026 19:48:56 +0400 Subject: [PATCH 2/5] update --- .../vality/rateboss/client/bi/BiApiClient.kt | 30 ++++++++++- .../source/impl/BiExchangeRateSource.kt | 50 ++++++++++--------- .../source/BiExchangeRateSourceTest.kt | 47 +++++++++++------ 3 files changed, 87 insertions(+), 40 deletions(-) diff --git a/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt b/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt index e84e0be..df4f882 100644 --- a/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt +++ b/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt @@ -2,7 +2,9 @@ package dev.vality.rateboss.client.bi import dev.vality.rateboss.config.properties.RatesProperties import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod +import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.web.client.RestTemplate import org.springframework.web.client.exchange @@ -19,8 +21,28 @@ class BiApiClient( startDate: LocalDate, endDate: LocalDate, ): String { + val opUrl = "${ratesProperties.source.bi.rootUrl}?op=getSubKursLokal3" + val warmupHeaders = HttpHeaders() + warmupHeaders.accept = listOf(MediaType.TEXT_HTML, MediaType.APPLICATION_XHTML_XML, MediaType.APPLICATION_XML) + warmupHeaders["Accept-Language"] = "en-US,en;q=0.9" + warmupHeaders["User-Agent"] = BROWSER_USER_AGENT + val warmupResponse = + restTemplate.exchange( + opUrl, + HttpMethod.GET, + HttpEntity(warmupHeaders), + ) + val cookieHeader = warmupResponse.headers["Set-Cookie"]?.joinToString("; ") { it.substringBefore(";") } + val url = buildUrl(currencySymbolCode, startDate, endDate) - return restTemplate.exchange(url, HttpMethod.GET, HttpEntity.EMPTY).body!! + val requestHeaders = HttpHeaders() + requestHeaders.accept = listOf(MediaType.APPLICATION_XML, MediaType.TEXT_XML, MediaType.ALL) + requestHeaders["Referer"] = opUrl + requestHeaders["User-Agent"] = BROWSER_USER_AGENT + if (!cookieHeader.isNullOrBlank()) { + requestHeaders["Cookie"] = cookieHeader + } + return restTemplate.exchange(url, HttpMethod.GET, HttpEntity(requestHeaders)).body!! } private fun buildUrl( @@ -35,4 +57,10 @@ class BiApiClient( .queryParam("enddate", endDate) .build() .toUriString() + + companion object { + private const val BROWSER_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + } } diff --git a/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt b/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt index 62b1ee5..e715ebe 100644 --- a/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt +++ b/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt @@ -15,6 +15,7 @@ import java.math.BigDecimal import java.math.MathContext import java.nio.charset.StandardCharsets import java.time.LocalDate +import java.time.OffsetDateTime import java.time.ZoneOffset import javax.xml.parsers.DocumentBuilderFactory @@ -83,6 +84,7 @@ class BiExchangeRateSource( .parse(ByteArrayInputStream(xmlContent.toByteArray(StandardCharsets.UTF_8))) val tables = document.getElementsByTagName("Table") + var lastTimestamp: OffsetDateTime? = null var idrPerTargetCurrency: BigDecimal? = null for (i in 0 until tables.length) { @@ -91,13 +93,19 @@ class BiExchangeRateSource( continue } val table = tableNode as Element - val parsedValue = - extractDecimalByTags( - table, - listOf("kurs_tengah", "kursjual", "kurs_jual", "kursbeli", "kurs_beli"), - ) - if (parsedValue != null && parsedValue.compareTo(BigDecimal.ZERO) > 0) { - idrPerTargetCurrency = parsedValue + val parsedDate = + extractTextByTag(table, "tgl_subkurslokal") + ?.let { OffsetDateTime.parse(it.trim()) } + ?: continue + val nominal = extractDecimalByTag(table, "nil_subkurslokal") + val sell = extractDecimalByTag(table, "jual_subkurslokal") + if (nominal == null || nominal <= BigDecimal.ZERO || sell == null || sell <= BigDecimal.ZERO) { + continue + } + val normalizedSell = sell.divide(nominal, MathContext.DECIMAL64) + if (lastTimestamp == null || parsedDate.isAfter(lastTimestamp)) { + lastTimestamp = parsedDate + idrPerTargetCurrency = normalizedSell } } @@ -106,24 +114,18 @@ class BiExchangeRateSource( return BigDecimal.ONE.divide(rate, MathContext.DECIMAL64) } - private fun extractDecimalByTags( + private fun extractDecimalByTag( parent: Element, - tags: List, - ): BigDecimal? { - for (tag in tags) { - val value = - parent - .getElementsByTagName(tag) - .item(0) - ?.textContent - ?.normalizeDecimal() - ?.toBigDecimalOrNull() - if (value != null) { - return value - } - } - return null - } + tag: String, + ): BigDecimal? = + extractTextByTag(parent, tag) + ?.normalizeDecimal() + ?.toBigDecimalOrNull() + + private fun extractTextByTag( + parent: Element, + tag: String, + ): String? = parent.getElementsByTagName(tag).item(0)?.textContent private fun String?.normalizeDecimal(): String = this diff --git a/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt b/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt index 3936921..c9324dd 100644 --- a/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt @@ -59,11 +59,18 @@ class BiExchangeRateSourceTest { val currencySymbolCode = "IDR" whenever(biApiClient.getExchangeRates(any(), any(), any())).thenReturn( """ - - - 16400,00 -
-
+ + + + + 1.00 + 17654.28 + 17831.72 + 2026-05-26T00:00:00+07:00 +
+
+
+
""".trimIndent(), ) @@ -72,7 +79,7 @@ class BiExchangeRateSourceTest { assertNotNull(exchangeRate) assertTrue(exchangeRate.rates.isNotEmpty()) assertTrue(exchangeRate.rates.containsKey("USD")) - assertEquals(BigDecimal("0.00006097560975609756"), exchangeRate.rates["USD"]) + assertEquals(BigDecimal("0.00005607983974624994"), exchangeRate.rates["USD"]) } @Test @@ -80,19 +87,29 @@ class BiExchangeRateSourceTest { val currencySymbolCode = "IDR" whenever(biApiClient.getExchangeRates(any(), any(), any())).thenReturn( """ - - - -
- - 16300,00 -
-
+ + + + + 1.00 + + + 2026-05-27T00:00:00+07:00 +
+ + 1.00 + 17654.28 + 17831.72 + 2026-05-26T00:00:00+07:00 +
+
+
+
""".trimIndent(), ) val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) - assertEquals(BigDecimal("0.00006134969325153374"), exchangeRate.rates["USD"]) + assertEquals(BigDecimal("0.00005607983974624994"), exchangeRate.rates["USD"]) } } From 718b022ac9f8c87e5dc8b463f61eb1be9de9de65 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Thu, 28 May 2026 20:21:42 +0400 Subject: [PATCH 3/5] update --- README.md | 5 +++++ .../vality/rateboss/source/impl/BiExchangeRateSource.kt | 7 ++++++- .../dev/vality/rateboss/source/BiExchangeRateSourceTest.kt | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5110852..23b4f76 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,8 @@ For each configured source: - `ExchangeGrabberMasterJob` reads the list of base currencies from `rates.*Job.currencies`. - It spawns `ExchangeGrabberJob` per currency. - Each `ExchangeGrabberJob` calls the respective `ExchangeRateSource`, saves the rates, and (for some sources) emits Kafka events. + +## BI source notes + +- Source: Bank Indonesia +- Stored BI rate format: `target currency per 1000 IDR` (e.g. `USD per 1000 IDR`), i.e. `1000 / (jual_subkurslokal / nil_subkurslokal)`. diff --git a/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt b/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt index e715ebe..247cf5a 100644 --- a/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt +++ b/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt @@ -111,7 +111,8 @@ class BiExchangeRateSource( val rate = idrPerTargetCurrency ?: return null log.debug { "BI rate parsed for targetCurrency=$targetCurrency: idrPerTargetCurrency=$rate" } - return BigDecimal.ONE.divide(rate, MathContext.DECIMAL64) + // Use 1000 here instead of 1. + return BI_RATE_MULTIPLIER.divide(rate, MathContext.DECIMAL64) } private fun extractDecimalByTag( @@ -133,4 +134,8 @@ class BiExchangeRateSource( ?.replace(",", ".") ?.trim() .orEmpty() + + companion object { + private val BI_RATE_MULTIPLIER: BigDecimal = BigDecimal.valueOf(1000L) + } } diff --git a/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt b/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt index c9324dd..45ee2ab 100644 --- a/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt @@ -79,7 +79,7 @@ class BiExchangeRateSourceTest { assertNotNull(exchangeRate) assertTrue(exchangeRate.rates.isNotEmpty()) assertTrue(exchangeRate.rates.containsKey("USD")) - assertEquals(BigDecimal("0.00005607983974624994"), exchangeRate.rates["USD"]) + assertEquals(BigDecimal("0.05607983974624994"), exchangeRate.rates["USD"]) } @Test @@ -110,6 +110,6 @@ class BiExchangeRateSourceTest { val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) - assertEquals(BigDecimal("0.00005607983974624994"), exchangeRate.rates["USD"]) + assertEquals(BigDecimal("0.05607983974624994"), exchangeRate.rates["USD"]) } } From a856b001d1009487340b0c5e8b42f09c34176253 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Thu, 28 May 2026 20:24:55 +0400 Subject: [PATCH 4/5] update --- .../kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt | 1 + .../dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt | 1 + .../kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt | 1 + .../kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt | 1 + .../kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt | 1 + .../kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt | 1 + 6 files changed, 6 insertions(+) diff --git a/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt index 7e4fe9b..8ee2560 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt @@ -52,6 +52,7 @@ class CbrExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt index 18f9e86..c582439 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt @@ -63,6 +63,7 @@ class FixerExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt index 79a61e3..4e49ed0 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt @@ -52,6 +52,7 @@ class NbazExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt index 66a9113..b42766b 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt @@ -52,6 +52,7 @@ class NbkrExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt index d775b94..c56a0b8 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt @@ -52,6 +52,7 @@ class NbkzExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt index d122ef8..090bf2b 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt @@ -52,6 +52,7 @@ class NbuzExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } @Test From 3281dd8ab2d57cca0d6518f949ccfb77ce83d546 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Fri, 29 May 2026 13:32:21 +0400 Subject: [PATCH 5/5] update --- .../vality/rateboss/client/bi/BiApiClient.kt | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt b/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt index df4f882..57d064d 100644 --- a/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt +++ b/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt @@ -4,12 +4,12 @@ import dev.vality.rateboss.config.properties.RatesProperties import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod -import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.web.client.RestTemplate import org.springframework.web.client.exchange import org.springframework.web.util.UriComponentsBuilder import java.time.LocalDate +import java.time.format.DateTimeFormatter @Component class BiApiClient( @@ -21,27 +21,9 @@ class BiApiClient( startDate: LocalDate, endDate: LocalDate, ): String { - val opUrl = "${ratesProperties.source.bi.rootUrl}?op=getSubKursLokal3" - val warmupHeaders = HttpHeaders() - warmupHeaders.accept = listOf(MediaType.TEXT_HTML, MediaType.APPLICATION_XHTML_XML, MediaType.APPLICATION_XML) - warmupHeaders["Accept-Language"] = "en-US,en;q=0.9" - warmupHeaders["User-Agent"] = BROWSER_USER_AGENT - val warmupResponse = - restTemplate.exchange( - opUrl, - HttpMethod.GET, - HttpEntity(warmupHeaders), - ) - val cookieHeader = warmupResponse.headers["Set-Cookie"]?.joinToString("; ") { it.substringBefore(";") } - val url = buildUrl(currencySymbolCode, startDate, endDate) val requestHeaders = HttpHeaders() - requestHeaders.accept = listOf(MediaType.APPLICATION_XML, MediaType.TEXT_XML, MediaType.ALL) - requestHeaders["Referer"] = opUrl requestHeaders["User-Agent"] = BROWSER_USER_AGENT - if (!cookieHeader.isNullOrBlank()) { - requestHeaders["Cookie"] = cookieHeader - } return restTemplate.exchange(url, HttpMethod.GET, HttpEntity(requestHeaders)).body!! } @@ -53,12 +35,13 @@ class BiApiClient( UriComponentsBuilder .fromUriString(ratesProperties.source.bi.rootUrl) .queryParam("mts", currencySymbolCode) - .queryParam("startdate", startDate) - .queryParam("enddate", endDate) + .queryParam("startdate", startDate.format(BI_DATE_FORMATTER)) + .queryParam("enddate", endDate.format(BI_DATE_FORMATTER)) .build() .toUriString() companion object { + private val BI_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd-MM-yyyy") private const val BROWSER_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"