Skip to content

Сервис для обработки валютных транзакций в реальном времени (интеграция openexchange-api)

Notifications You must be signed in to change notification settings

alibekbirlikbai/microservice-expenses

Repository files navigation

Тестовое задание Junior Java разработчика Solva.kz

Documentation

Intro

Целью данного проекта является разработка прототипа микросервиса для анализа и обработки Транзакций Клиента в реальном времени в разных валютах (KZT, RUB и другие), с возможностью установления месячного Лимита на определенную сумму (USD), для интеграции в существующую банковскую систему

Всего проект состоит из 5 сервисов

Сервисы конфигурации:

  1. service-registry: Обеспечивает механизм регистрации и обнаружения микросервисов

  2. config-server: Позволяет управлять конфигурациями микросервисов в одном месте

  3. api-gateway: Позволяет использовать общий порт (port:8060) для всех сервисов

Сервисы с бизнес-логикой:

  1. transaction-service: отвечает за прием и обработку "Транзакций", и запросов от "Клиента"
  2. currency-service: отвечает за получение актуальных "курсов валют" (обращяется к Внешнему API)

Внешний API:

Info: Это API к которому обращяется проект для получения актуальных курсов валют

Features

Пункты из ТЗ + Алгоритм выполнения для каждого пункта:

  • [п.1] Получать информацию о каждой расходной операции в тенге (KZT), рублях (RUB) и других валютах в реальном времени и сохранять ее в своей собственной базе данных (БД);

  • [п.2] Хранить месячный лимит по расходам в долларах США (USD) раздельно для двух категорий расходов: товаров и услуг. Если не установлен, принимать лимит равным 1000 USD;

  • [п.3] Запрашивать данные биржевых курсов валютных пар KZT/USD, RUB/USD по дневному интервалу (1day/daily) и хранить их в собственной базе данных. При расчете курсов использовать данные закрытия (close). В случае, если таковые недоступны на текущий день (выходной или праздничный день), то использовать данные последнего закрытия (previous_close);

    • Установить соединение с Внешним API (описание Кода) (Git commit)
    • Запрашивать "курсы валют" к USD (описание Кода) (Git commit)
    • Обработать полученные валюты от Внешнего API (описание Кода) (Git commit)
    • Реализовать логику получения "курсов валют" последнего закрытия (описание Кода) (Git commit)

      если "Транзакция" совершена в выходной или праздничный день

    • [--Доп. features--]:
      • [п.3.1] Рассчитывать сумму расходов в USD нужно по биржевому курсу на день расхода или по последнему курсу закрытия. За каждый запрос внешних данных нужно платить, и, к тому же, на выполнение внешнего запроса тратится дополнительное время. В связи с этим, полученные обменные курсы валют нужно хранить в своей базе данных и преимущественно использовать их;

        Info: Данные биржевых торгов получать из внешнего источника данных (twelvedata.com, alphavantage.co, openexchangerates.org или из другого по своему усмотрению.

        • Сохранение полученных "курсов валют" на определенную дату в локальном БД (описание Кода) (Git commit)
        • Реализовать логику проверки наличия "курсов валют" на определенную дату в локальном БД (описание Кода) (Git commit)

          при следующей "Транзакция" на тот же день, данные будут взяты из БД, запрос не будет оправляться на внешний API если они уже есть в БД

  • [п.4] Помечать транзакции, превысившие месячный лимит операций (технический флаг limit_exceeded);

    • Реализовать логику выставления флага limit_exceeded для "Транзакции" (описание Кода) (Git commit)

    • Реализовать логику конвертации валюты "Транзакций" (описание Кода) (Git commit)

      это логика должна учитываться при вычислении Остатка месячного лимита

    • Реализовать обращение к currency-service для получения списка валют на дату совершения "Транзакции" (описание Кода) (Git commit)

    • [--Доп. features--]:

      • [п.4.1] Последний лимит не должен влиять на выставление флага limit_exceeded транзакциям, совершенным ранее установления последнего лимита;

        Info: Иными словами, если лимит, установленный 1.01.2022 в размере 1000 USD, превышен двумя транзакциями на суммы 500 и 600 USD, то второй транзакции должен быть выставлен флаг limit_exceeded = true. Если пользователь установил новый лимит 11.01.2022, и выполнил третью транзакцию 12.01.2022 на сумму 100 USD, она должна иметь флаг limit_exceeded = false.

  • [п.5] Дать возможность клиенту установить новый лимит. При установлении нового лимита микросервисом автоматически выставляется текущая дата, не позволяя выставить ее в прошедшем или будущем времени. Обновлять существующие лимиты запрещается;

  • [п.6] По запросу клиента возвращать список транзакций, превысивших лимит, с указанием лимита, который был превышен (дата установления, сумма лимита, валюта (USD));

    • Реализовать функцию получения "Транзакции", которые перевесили свой "Лимит" (описание Кода) (Git commit)
    • [--Доп. features--]:
      • [п.5.1] При получении лимитов, в SQL запросе пользуйтесь JOIN с подзапросом, агрегирующими функциями и группировками;

Implementation

transaction-service
  • Основные Классы:

    model:

    • class Transaction: объект для работы с "Транзакциями"
    • class Limit: объект для работы с "Лимитами"
    • enum ExpenseCategory: для определения доступных категорий расходов "Транзакциями"

      Note: "Транзакциями" не сохраняется если не соответствует хотя-бы одной из категорий

    • class Currency: объект для работы с "курсами валют" из currency-service

      имеет доп параметр CurrencyRequest, котоый содержит доп информацию для конкретной валюты (валютная пара - "base":"USD" , дата курса - "formatted_timestamp": "2024-04-26")

    • class ExceededTransactionDTO: DTO объект (содержит "Транзакциями" и Лимит) для работы с "Транзакциями" превысевшими свой "Лимит"

    service:

    • interface TransactionService: бизнес логика для работы с - class Transaction
    • interface LimitService: бизнес логика для работы с class Limit
    • interface CurrencyService: бизнес логика для работы с - class Currency (взаимодействие с currency-service)

    external:

    • class CurrencyServiceClient: отвечает за взаимодействие currency-service

  • Реализация Features
    • 1. Сохранение получаемой "Транзакций"

      Для сохранения транзакции используется метод - save()

      @Override
      public Transaction save(Transaction transaction) {
          TransactionServiceUtils.validateTransactionData(transaction);
      
          //...
          
          return repository.save(transaction);
      }
    • 2. Реализовать логику разделение "Транзакций" по категориям расходов

      За определение категории расходов для "Транзакций" отвечает - enum ExpenseCategory

      public enum ExpenseCategory {
          PRODUCT,
          SERVICE
      }
    • 3. Реализовать логику установления "Лимита" по умолчанию

      За определение Лимита по-умолчанию отвечает метод - setDefaultLimit()

      Note: дата установления = 1st число месяца, в котором была совершена "Транзакций"

      @Override
      public Limit setDefaultLimit(Transaction transaction) {
          Limit limit = new Limit();
          limit.setId(0);
          limit.setLimit_currency_shortname("USD");
          limit.setLimit_sum(BigDecimal.valueOf(1000.00));
          limit.setLimit_datetime(ServiceUtils.getStartOfMonthDateTime(transaction.getDatetime()));
          limit.setExpense_category(transaction.getExpense_category());
          return limit;
      }
    • 4. Реализовать логику выставления флага limit_exceeded для "Транзакции"

      Для выставление флага limit_exceeded для "Транзакций" отвечает метод - checkTransactionForExceed()

      @Override
      public Transaction save(Transaction transaction) {
          //...
      
          transaction.setLimit_exceeded(checkTransactionForExceed(transaction, limit));
          return repository.save(transaction);
      }
      @Override
      public boolean checkTransactionForExceed(Transaction transaction, Limit limit) {
          /* Чтобы не сохранять конвертированный (в USD) вариант как сумму для текущей транзакции,
           * при расчете limitSumLeft передаем в нее копию  */
          Transaction transactionCopy = transaction.clone();
      
          BigDecimal limitSumLeft = limitService.calculateLimitSumLeft(transactionCopy, limit);
      
          // Если limitSumLeft отрицательный или 0.0, то лимит был превышен
          if (limitSumLeft.compareTo(BigDecimal.ZERO) < 0) {
              return true;
          }
          return false;
      }
      @Override
      public BigDecimal calculateLimitSumLeft(Transaction transaction, Limit limit) {
          ZonedDateTime limitStartDate = limit.getLimit_datetime();
          ZonedDateTime transactionDateTime = transaction.getDatetime();
          ExpenseCategory transactionCategory = transaction.getExpense_category();
      
          // Обновляем лимит на начало месяца транзакции, если она произошла после установления лимита
          if (transactionDateTime.isAfter(limitStartDate)) {
              limitStartDate = ZonedDateTime.of(transactionDateTime.getYear(), transactionDateTime.getMonthValue(), 1, 0, 0, 0, 0, transactionDateTime.getZone());
          }
      
          // Получаем все транзакции клиента за месяц
          List<Transaction> transactions = transactionService.getClientTransactionListForMonth(transactionDateTime);
          transactions.add(transaction);
      
          // Фильтруем транзакции, которые произошли после установления лимита в текущем месяце
          ZonedDateTime finalLimitStartDate = limitStartDate;
          List<Transaction> relevantTransactions = transactions.stream()
                  .filter(t -> t.getDatetime().isAfter(finalLimitStartDate))
                  .filter(t -> t.getExpense_category() == transactionCategory) // Учитываем категорию расходов
                  .map(t -> {
                      Transaction transactionCopy = t.clone();
      
                      // Конвертируем сумму транзакции в USD
                      transactionCopy.setSum(currencyService.convertToUSD(t.getCurrency_shortname(), t.getSum(), t.getDatetime())); // Конвертируем сумму транзакции в USD
                      return transactionCopy;
                  })
                  .collect(Collectors.toList());
      
          // Вычисляем остаток лимита
          BigDecimal remainingLimit = limit.getLimit_sum();
          for (Transaction t : relevantTransactions) {
              remainingLimit = remainingLimit.subtract(t.getSum());
          }
          System.out.println("remainingLimit: " + remainingLimit);
          return remainingLimit;
      }
    • 5. Реализовать логику конвертации валюты "Транзакции"

      Для конвертации параметра "Транзакций" sum к 'USD' используется метод - convertToUSD()

      @Override
      public BigDecimal convertToUSD(String currency_shortname, BigDecimal transaction_sum, ZonedDateTime transaction_dateTime) {
          // Получаем список валют и их курсов (из API currency-service)
          List<Currency> currencyList = fetchCurrencyList(transaction_dateTime).block();
          Map<String, BigDecimal> currencyMap = getListOfCurrency(currencyList);
      
          // Проверяем, есть ли указанная валюта в списке
          if (!currencyMap.containsKey(currency_shortname)) {
              return BigDecimal.ZERO;
          }
      
          // Получаем курс валюты к USD
          BigDecimal exchangeRate = currencyMap.get(currency_shortname);
      
          // Конвертируем сумму в USD по курсу
          return transaction_sum.divide(exchangeRate, 2, RoundingMode.HALF_UP);
      }

      Где метод fetchCurrencyList() обращяется к currency-service для получения списка валют на дату совершения "Транзакций", и метод getListOfCurrency() обрабатывает этот список и возвращяет его в формате Map<"Валюта","Курс к USD">

      @Override
      public Map<String, BigDecimal> getListOfCurrency(List<Currency> currencyList) {
          Map<String, BigDecimal> currencyRatesMap = new HashMap<>();
          // Добавляем все валюты и их курсы к USD из списка currencyList
          for (Currency currency : currencyList) {
              currencyRatesMap.put(currency.getCurrency_shortname(), currency.getRate_to_USD());
          }
      
          return currencyRatesMap;
      }
    • 6. Реализовать обращение к currency-service для получения списка валют на дату совершения "Транзакции"

      За обращение к currency-service отвечает - class CurrencyServiceClient, который использует WebClient для выполнения HTTP-запросов к сервису

      @Component
      public class CurrencyServiceClient {
          private final WebClient webClient;
      
          @Autowired
          public CurrencyServiceClient(WebClient.Builder webClientBuilder) {
              this.webClient = webClientBuilder
                      .filter((request, next) -> {
                          System.out.println("Request: " + request.method() + " " + request.url());
                          return next.exchange(request);
                      })
                      .build();
          }
      
          // http://localhost:8082/currency-service/api/currencies/{dateTime}
          public Mono<List<Currency>> getCurrencyList(ZonedDateTime dateTime) {
              return webClient.get()
                      .uri(uriBuilder ->
                              uriBuilder.scheme("http")
                                      .host("localhost")
                                      .port(8082)
                                      .path("/currency-service/api/currencies/" + dateTime)
                                      .build())
                      .retrieve()
                      .bodyToFlux(Currency.class)  // Преобразуем ответ в поток объектов Currency
                      .collectList();              // Собираем объекты Currency в список
          }
      }

      Для получения и обработки списка валют отвечает метод - fetchCurrencyList()

      @Override
      public Mono<List<Currency>> fetchCurrencyList(ZonedDateTime transaction_dateTime) {
          List<Currency> currencyList = new ArrayList<>();
          return currencyServiceClient.getCurrencyList(transaction_dateTime)
                  .doOnNext(response -> {
                      // Преобразование и добавление в currencyList
                      List<Currency> returnedCurrencies = response.stream()
                              .map(currency -> {
                                  Currency newCurrency = new Currency();
                                  newCurrency.setCurrency_shortname(currency.getCurrency_shortname());
                                  newCurrency.setRate_to_USD(currency.getRate_to_USD());
      
                                  CurrencyRequest newCurrencyRequest = new CurrencyRequest();
                                  newCurrencyRequest.setBase(currency.getCurrencyRequest().getBase());
                                  newCurrencyRequest.setFormatted_timestamp(currency.getCurrencyRequest().getFormatted_timestamp());
      
                                  newCurrency.setCurrencyRequest(newCurrencyRequest);
      
                                  return newCurrency;
                              })
                              .collect(Collectors.toList());
                      currencyList.addAll(returnedCurrencies);
                  });
      }
    • 7. Реализовать функцию установления "Лимита" Клиентом

      За сохранение "Лимита" Клиента отвечает метод - setClientLimit()

      @Override
      public Limit setClientLimit(Limit limit) {
            // Обновлять существующие лимиты запрещается
            checkLimitForExist(limit);
      
            /* автоматически выставляется текущая дата,
            * не позволяя выставить ее в прошедшем или будущем времени */
            limit.setLimit_datetime(ServiceUtils.getCurrentDateTime());
      
            // Лимит всегда USD
            limit.setLimit_currency_shortname("USD");
            return repository.save(limit);
        }

      После того как Клиент определит собственный "Лимит", перед выставлением флага limit_exceeded определяется актуальный "Лимит" для "Транзакций". Сначала проверяется есть ли в БД хотябы 1 "Лимит" удовлетворяющий параметру expense_category "Транзакций", если есть то он используется при вычсилении "Остатка месячного Лимита" для "Транзакций", если его нет используется "Лимит" по-умолчанию (1000.00 USD)

      @Override
      public Transaction save(Transaction transaction) {
          //...
      
          Limit limit = new Limit();
          if (limitService.hasRecords()) {
              Map<ExpenseCategory, Limit> latestLimits = limitService.getLatestLimitsForCategories();
      
              // Проверяем, соответствует ли ExpenseCategory транзакции одной из категорий в возвращаемой Map
              Limit limitForTransactionCategory = latestLimits.get(transaction.getExpense_category());
      
              if (limitForTransactionCategory != null) {
                  /// Лимит Клиента
                  BeanUtils.copyProperties(limitForTransactionCategory, limit);
                  ServiceUtils.roundToHundredth(limit.getLimit_sum());
              } else {
                  // Лимит по умолчанию (1000.00)
                  /* на тот случай если Клиент установил лимит
                   * только для 1 из категорий ExpenseCategory */
                  limit = limitService.setDefaultLimit(transaction);
              }
            } else {
                // Лимит по умолчанию (1000.00)
                /* на тот случай если Клиент никогда не устанавливал своего лимита
                 * т.е. для всех ExpenseCategory в месяце в котором была совершена транзакция
                 * лимит = 1000.00 */
                limit = limitService.setDefaultLimit(transaction);
            }
      
            //...
              
            return repository.save(transaction);
      }
    • 8. Реализовать функцию получения "Транзакции", которые превысили свой "Лимит"

      • Реализация через SQL-запрос

        SELECT t.id AS t_id,
             t.account_from,
             t.account_to,
             t.currency_shortname,
             t.sum,
             t.expense_category,
             t.datetime,
             t.limit_exceeded,
             CASE
                 WHEN COALESCE(l.id, -1) = -1 THEN 0
                 ELSE l.id
             END AS limit_id,
             CASE
                 WHEN COALESCE(l.id, 0) = 0 THEN 1000.00
                 ELSE l.limit_sum
             END AS limit_sum,
             CASE
                 WHEN COALESCE(l.id, 0) = 0 THEN DATE_TRUNC('month', t.datetime) + INTERVAL '1 DAY'
                 ELSE l.limit_datetime
             END AS limit_datetime,
             CASE
                 WHEN COALESCE(l.id, 0) = 0 THEN 'USD'
                 ELSE l.limit_currency_shortname
             END AS limit_currency_shortname,
             CASE
                 WHEN COALESCE(l.id, 0) = 0 THEN t.expense_category
                 ELSE COALESCE(l.expense_category, t.expense_category)
             END AS limit_expense_category
         FROM Transaction t
         LEFT JOIN (
             SELECT t.id, MAX(l.limit_datetime) AS max_limit_datetime
             FROM Transaction t
             JOIN Limits l ON t.expense_category = l.expense_category AND t.datetime >= l.limit_datetime -- Фильтрация транзакции по (категории) + (времени)
             WHERE t.limit_exceeded = true
             GROUP BY t.id
         ) AS t_max_limit ON t.id = t_max_limit.id AND t.limit_exceeded = true
         LEFT JOIN Limits l ON t.expense_category = l.expense_category
                     AND l.limit_datetime = t_max_limit.max_limit_datetime
         WHERE t.limit_exceeded = true -- Добавляем фильтрацию по limit_exceeded
         ORDER BY t.datetime DESC; -- Сортировка ответа, чтобы сначала были самые актуальные транзакции
        @Override
        public List<ExceededTransactionDTO> getAllExceededTransactions_SQL() {
             String sqlQuery = "
                               //... SQL-Запрос
                                ";
        
             Query query = entityManager.createNativeQuery(sqlQuery);
             List<Object[]> resultList = query.getResultList();
        
             List<ExceededTransactionDTO> exceededTransactionDTOs = new ArrayList<>();
             for (Object[] result : resultList) {
                 // Проверяем значение limit_exceeded
                 if ((Boolean) result[7]) {
                     Map<String, Object> transactionMap = new LinkedHashMap<>();
                     transactionMap.put("id", result[0]);
                     transactionMap.put("account_from", result[1]);
                     transactionMap.put("account_to", result[2]);
                     transactionMap.put("currency_shortname", result[3]);
                     transactionMap.put("sum", result[4]);
                     transactionMap.put("expense_category", result[5]);
                     transactionMap.put("datetime", result[6]);
                     transactionMap.put("limit_exceeded", result[7]);
        
                     ExceededTransactionDTO dto = new ExceededTransactionDTO();
                     Map<String, Object> limitMap = new LinkedHashMap<>();
        
                     if ((Long) result[8] != 0) {
                         // Если есть Client limits
                         limitMap.put("id", result[8]);
                         limitMap.put("limit_sum", result[9]);
                         limitMap.put("limit_datetime", result[10]);
                         limitMap.put("limit_currency_shortname", result[11]);
                         limitMap.put("expense_category", result[12]);
        
                         dto.setLimit(LimitServiceUtils.convertMapToLimit(limitMap));
                     } else {
                         // Если нет Client limits
                         Limit defaultLimit = limitService.setDefaultLimit(TransactionServiceUtils.convertMapToTransaction(transactionMap));
        
                         limitMap.put("id", defaultLimit.getId());
                         limitMap.put("limit_sum", defaultLimit.getLimit_sum());
                         limitMap.put("limit_datetime", defaultLimit.getLimit_datetime());
                         limitMap.put("limit_currency_shortname", defaultLimit.getLimit_currency_shortname());
                         limitMap.put("expense_category", defaultLimit.getExpense_category());
                         dto.setLimit(defaultLimit);
                     }
        
                     dto.setTransaction(TransactionServiceUtils.convertMapToTransaction(transactionMap));
        
                     exceededTransactionDTOs.add(dto);
                 }
             }
        
             return exceededTransactionDTOs;
         }
      • Реализация через Java-код

        @Override
        public List<ExceededTransactionDTO> getAllExceededTransactions_Java() {
             // Получаем все транзакции, которые превысили лимиты
             List<Transaction> exceededTransactions = repository.findByLimit_exceededTrue();
        
             // Создаем список DTO для превышенных транзакций
             List<ExceededTransactionDTO> exceededTransactionDTOs = new ArrayList<>();
        
             // Проходим по каждой превышенной транзакции
             for (Transaction transaction : exceededTransactions) {
                 // Получаем лимит для данной транзакции
                 Limit limit = limitService.getLimitForTransaction(transaction);
        
                 // Создаем DTO и устанавливаем транзакцию и соответствующий лимит
                 ExceededTransactionDTO dto = new ExceededTransactionDTO();
                 dto.setTransaction(transaction);
                 dto.setLimit(limit);
        
                 // Добавляем DTO в список
                 exceededTransactionDTOs.add(dto);
             }
             Collections.reverse(exceededTransactionDTOs);
             return exceededTransactionDTOs;
         }
currency-service
  • Основные Классы:

    model:

    • class Currency: объект для работы с "курсами валют"
    • class CurrencyApiResponse: объект для работы response от Внешнего API
    • class CurrencyRequest: объект для сохранения информации о прошлых запросах к Внешнему API

    service:

    • class CurrencyService: бизнес логика для работы с - class Currency, class CurrencyApiResponse, class CurrencyRequest

    external:

    • class OpenExchangeRatesClient: отвечает за взаимодействие c Внешним API

  • Реализация Features
    • 1. Установить соединение с Внешним API

      За обеспечение соединения с Внешним API отвечает - class OpenExchangeRatesClient, в котором app_id и basePath определен в config-server\src\main\resources\config\currency-service.yaml

      @Component
      public class OpenExchangeRatesClient {
          private final WebClient webClient;
      
          @Value("${my.API_id}")
          private String app_id;
      
          @Value("${my.API_basePath}")
          private String basePath;
      
          @Autowired
          public OpenExchangeRatesClient(WebClient.Builder webClientBuilder) {
              this.webClient = webClientBuilder
                      .filter((request, next) -> {
                          System.out.println("Request: " + request.method() + " " + request.url());
                          return next.exchange(request);
                      })
                      .build();
          }
      
          // ...
      }

      В этом классе метод - getCurrencyList_Latest() отвечает за получение списка "курсов валют" на текущий момент (up-to-date)

      Note: метод обращяется к API /latest.json от https://openexchangerates.org/api

      // ...
      public Mono<CurrencyApiResponse> getCurrencyList_Latest() {
          return webClient.get()
                  .uri(uriBuilder ->
                          uriBuilder.scheme("https")
                                  .host(basePath)
                                  .path("/api/latest.json")
                                  .queryParam("app_id", app_id)
                                  .build())
                  .retrieve()
                  .bodyToMono(CurrencyApiResponse.class);
      }
      // ...

      и метод - getCurrencyList_Historical() отвечает за получение списка "курсов валют" на определенную дату в прошлом

      Note: метод обращяется к API /historical/*.json от https://openexchangerates.org/api

      // ...
      public Mono<CurrencyApiResponse> getCurrencyList_Historical(String dateTime) {
          return webClient.get()
                  .uri(uriBuilder ->
                          uriBuilder.scheme("https")
                                  .host(basePath)
                                  .path("/api/historical/" + dateTime + ".json")
                                  .queryParam("app_id", app_id)
                                  .build())
                  .retrieve()
                  .bodyToMono(CurrencyApiResponse.class);
      }
      // ...
    • 2. Обработка полученных валют от Внешнего API

      За получение и обработку валют от Внешнего API отвечает метод - getCurrencyList()

      Note: в этом методе применяются принципы реактивного программирования от WebClient

      @Override
      public Mono<List<Currency>> getCurrencyList(ZonedDateTime transaction_dateTime) {
          String currentDate_formatted = CurrencyServiceUtils.parseZoneDateTime(CurrencyServiceUtils.getCurrentDateTime());
          String transactionDate_formatted = CurrencyServiceUtils.parseZoneDateTime(transaction_dateTime);
          LocalDate parsedCurrentDate = LocalDate.parse(currentDate_formatted);
          LocalDate parsedTransactionDate = LocalDate.parse(transactionDate_formatted);
      
          CurrencyRequest pastCurrencyRequest = requestRepository.findByFormatted_timestamp(transactionDate_formatted);
          if (pastCurrencyRequest != null) {
              System.out.println("Get data from local db!!! (currencyList at:" + pastCurrencyRequest.getFormatted_timestamp() + ")");
      
              // Список валют взят из БД (запрос в openexchangerates.org/api/ НЕ делается)
              currencyList = currencyRepository.findAllByCurrencyRequestID(pastCurrencyRequest.getId());
              return checkForUnavailableRate(transaction_dateTime, currencyList);
          } else {
              return Mono.defer(() -> {
                  if (parsedTransactionDate.isEqual(parsedCurrentDate)) {
                      return openExchangeRatesClient.getCurrencyList_Latest()
                              .map(response -> {
                                  response.setTimestamp(currentDate_formatted);
                                  return response;
                              })
                              .flatMap(response -> createCurrenciesFromResponse(Mono.just(response)))
                              .flatMap(currencyList -> checkForUnavailableRate(transaction_dateTime, currencyList));
                  } else if (parsedTransactionDate.isBefore(parsedCurrentDate)) {
                      return openExchangeRatesClient.getCurrencyList_Historical(transactionDate_formatted)
                              .map(response -> {
                                  response.setTimestamp(transactionDate_formatted);
                                  return response;
                              })
                              .flatMap(response -> createCurrenciesFromResponse(Mono.just(response)))
                              .flatMap(currencyList -> checkForUnavailableRate(transaction_dateTime, currencyList));
                  } else {
                      return Mono.error(new IllegalArgumentException("Transaction date cannot be in the future!!! (BACK-TO-THE-FUTURE)"));
                  }
              });
          }
      }
      @Override
      public Mono<List<Currency>> createCurrenciesFromResponse(Mono<CurrencyApiResponse> responseMono) {
          return responseMono.flatMap(response -> {
              CurrencyRequest currencyRequest = getCurrencyRequest(response);
              requestRepository.save(currencyRequest);
      
              // Получаем список валют и сохраняем их в бд
              Map<String, BigDecimal> currencyRates = response.getRates();
              currencyList = new ArrayList<>();
              for (Map.Entry<String, BigDecimal> entry : currencyRates.entrySet()) {
                  Currency currency = new Currency();
                  currency.setCurrency_shortname(entry.getKey());
                  currency.setRate_to_USD(entry.getValue());
                  currencyList.add(currency);
              }
              currencyList.forEach(currency -> currency.setCurrencyRequest(currencyRequest));
              currencyRepository.saveAll(currencyList);
              return Mono.just(currencyList);
          });
      }
      @Override
      public CurrencyRequest getCurrencyRequest(CurrencyApiResponse response) {
          CurrencyRequest currencyRequest = new CurrencyRequest();
          currencyRequest.setBase(response.getBase());
          currencyRequest.setFormatted_timestamp(response.getTimestamp());
          return currencyRequest;
      }
    • 3. Реализовать логику получения "курсов валют" последнего закрытия

      За определение категории расходов для "Транзакций" отвечает - enum ExpenseCategory

      /* это немного условная реализация требования из ТЗ (пункт 3) ("использовать данные последнего закрытия (previous_close)")
       * потому что у меня free-plan от https://openexchangerates.org/api/,
       * но в документации говорится что для '/historical/*.json' + '/latest.json'
       * в качестве rates фактический берутся данные последнего закрытия,
       * а значит что если запрос будет пустой,
       * то мы можем сделать еще один запрос на предыдущий день (чтобы получить previous_close rates)
       *      '/latest.json'=(The latest rates will always be the most up-to-date data available on your plan)
       *      '/historical/*.json'=(The historical rates returned are the last values we published for a given UTC day)  */
      @Override
      public Mono<List<Currency>> checkForUnavailableRate(ZonedDateTime transaction_dateTime, List<Currency> currencyList) {
          /* Если currencyList пустой, вызываем getCurrencyList на предыдущий день,
           * и так по рекурсии, пока не найдется доступный курс */
          if (currencyList.isEmpty()) {
              ZonedDateTime previousDate = transaction_dateTime.minusDays(1);
              return getCurrencyList(previousDate);
          } else {
              // Если currencyList не пустой, просто возвращаем значение
              return Mono.just(currencyList);
          }
      }

API

Note

Если api-gateway:

  • запущен:
    • port: 8060 (Общий для всех сервисов приложения)
  • не запущен:
    • port: 8081 (для transaction-service)
    • port: 8082 (для currency-service)
API-запросы для - transaction-service
  1. POST -> Для приема "Транзакций" (условно интеграция с банковскими сервисами)

    [Request]:

     http://localhost:8060/transaction-service/api/transactions
    
    {
        "account_from": "0000000123",
        "account_to": "9999999999",
        "currency_shortname": "USD",
        "sum": "500",
        "expense_category": "SERVICE"
    }

    "currency_shortname": KZT, RUB, USD, ...
    "expense_category": SERVICE / PRODUCT

    [Response]:

    Successfully saved
  2. POST -> Для установки нового "Лимита" Клиентами

    [Request]:

     http://localhost:8060/transaction-service/api/client/limits
    
    {
        "limit_sum": "2000",
        "limit_currency_shortname": "USD",
        "expense_category": "SERVICE"
    }

    "expense_category": SERVICE / PRODUCT

    [Response]:

    New limit Successfully saved
  3. GET -> Для получения всех "Лимитов" Клиентами

    [Request]:

     http://localhost:8060/transaction-service/api/client/limits
    

    [Response]:

    [
      {
          "id": 1,
          "limit_sum": 2000.0,
          "limit_datetime": "2024-04-01T00:00:00Z",
          "limit_currency_shortname": "USD",
          "expense_category": "SERVICE"
      },
      ... // Получаем все установленные Лимиты Клиента
    ]
  4. GET -> [SQL query] Для получение "Транзакций" превысивших "Лимит" (с указанием лимита, который был превышен)

    [Request]:

    http://localhost:8060/transaction-service/api/client/transactions/exceeded/sql-query
    

    [Response]:

    • ~ [DEFAULT Limit] (1000.00 USD):

      [
        {
          "transaction": {
              "id": 2,
              "account_from": 123,
              "account_to": 9999999999,
              "currency_shortname": "USD",
              "sum": 600.00,
              "expense_category": "SERVICE",
              "datetime": "2024-04-25T11:23:50.236144Z",
              "limit_exceeded": true
          },
          "limit": {
              "id": 0, // Лимит по-умолчанию
              "limit_sum": 1000.0,
              "limit_datetime": "2024-04-01T00:00:00Z",
              "limit_currency_shortname": "USD",
              "expense_category": "SERVICE"
          }
        },
        ... // Получаем все Транзакции превысившие свой Лимит
      ]
    • Client Limit:

      [
        {
          "transaction": {
              "id": 6,
              "account_from": 123,
              "account_to": 9999999999,
              "currency_shortname": "USD",
              "sum": 100.00,
              "expense_category": "SERVICE",
              "datetime": "2024-04-25T11:23:50.236144Z",
              "limit_exceeded": true
          },
          "limit": {
              "id": 1, // Клиентский Лимит
              "limit_sum": 2000.0,
              "limit_datetime": "2024-04-01T00:00:00Z",
              "limit_currency_shortname": "USD",
              "expense_category": "SERVICE"
          }
        },
          ... // Получаем все Транзакции превысившие свой Лимит
      ]
  5. GET -> [Java code] Для получение "Транзакций" превысивших "Лимит" (с указанием лимита, который был превышен)

    [Request]:

     http://localhost:8060/transaction-service/api/client/transactions/exceeded/java-code
    

    [Response]:

    Структура ответа такая же как для /exceeded/sql-query, но Java реализация логики

API-запросы для - currency-service
  1. GET -> Для получения "Курсов-валют" на определенную дату

    [Request]:

     http://localhost:8060/currency-service/api/currencies/2022-05-20T01:00:00+03:00
    

    Note: Контроллер на стороне currency-service, принимает параметр типа ZoneDateTime - "2022-05-20T01:00:00+03:00" от transaction-service, после чего дата будет приведена в формат "yyyy-mm-dd" для соответствия API-запросам к https://openexchangerates.org/api/

    [Response]:

    [
      {
          "id": 1,
          "currency_shortname": "AED",
          "rate_to_USD": 3.67,
          "currencyRequest": {
              "id": 102,
              "base": "USD",
              "formatted_timestamp": "2022-05-20"
          }
      },
      {
          "id": 2,
          "currency_shortname": "AFN",
          "rate_to_USD": 90.50,
          "currencyRequest": {
              "id": 102,
              "base": "USD",
              "formatted_timestamp": "2022-05-20"
          }
      },
      ... // Получаем все курсы валют на - "2022-05-20T01:00:00+03:00"
    ]
  2. GET -> Для получения списка запросов (совершенных к внешнему API)

    [Request]:

     http://localhost:8060/currency-service/api/currencies/requests
    

    [Response]:

    [
        {
            "id": 1,
            "base": "USD",
            "formatted_timestamp": "2022-05-20"
        },
        ... // Получаем все - предыдущие запросы к 'https://openexchangerates.org/api/'
    ]
  3. GET -> Для получения всех "Курсов-валют" для ранее совершенных запросов

    [Request]:

    http://localhost:8060/currency-service/api/currencies
    

    [Response]:

    [
      {
          "id": 1,
          "currency_shortname": "AED",
          "rate_to_USD": 3.67,
          "currencyRequest": {
              "id": 102,
              "base": "USD",
              "formatted_timestamp": "2022-05-20"
          }
      },
      {
          "id": 2,
          "currency_shortname": "AFN",
          "rate_to_USD": 90.50,
          "currencyRequest": {
              "id": 102,
              "base": "USD",
              "formatted_timestamp": "2022-05-20"
          }
      },
      ... // Получаем все курсы валют хранящиеся на локальном БД (предыдущие запросы к 'https://openexchangerates.org/api/')
    ]

Quickstart

https://github.com/alibekbirlikbai/microservice-expenses.git
  • Настройка Базы Данных
    • В PostgreSQL создайте 2 отдельные базы данных: currency_data и transaction_data

    • Для подключения к своему PostgreSQL пройдите до config-server\src\main\resources\config внутри заходите в currency-service.yaml/transaction-service.yaml и заменяете значения для username, password

      image image

    • (Опционально)

      Для currency-service, параметр hibernate.ddl-auto: update

      при желании его можно заменить на другой, но тогда после каждого запуска микросервиса хранящиеся данные о курсах валют в бд будут потеряны (придется занова обращаться к 'Внешнему API')

      image

  • Настройка Сервера
    • Как только настроите бд, запустите по порядку каждый из сервисов:

      • service-registry (1)
      • config-server (2)
      • api-gateway (3)
      • currency-service (4)
      • transaction-service (5)

      После запуска проект должен выглядеть так:
      Без имени

    • После чего можете запускать API-запросы (детали смотрите в разделе API

Branch info

  • main - Production ветка, стабильная (завершенная) версия проекта;
  • dev - Development ветка, интеграция всех features;

Stack

  • Java 17
  • Spring Boot
  • Spring WEB
  • Spring Data JPA
  • PostgreSQL
  • Maven

About

Сервис для обработки валютных транзакций в реальном времени (интеграция openexchange-api)

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages