Данный HTTP API микросервис предназначен для работы с балансом пользователей (зачисление и списание средств, перевод средств в любой мировой валюте от пользователя к пользователю, а также для получения информации о балансе пользователя и истории всех транзакций).
.env
файл был оставлен в репозитории для удобста проверки тестового задания;- В
.env
файле предоставлены данные по сервису, БД и exchangertestapi; - Swagger файл лежит в директории api/schema.yaml
- Выбрать базу данных из имеющихся для хранения и работы со счетом пользователя;
- Подобрать и реализовать способ работы с банковским счетом пользователя (где и каким образом хранить средства);
- Реализовать базовые методы зачисления, списания и перевода средств;
- Организовать работу с удаленным API калькулятором валют;
- Реализовать метод вывода истории переводов пользователя, включая идентификатор пользователя, тип перевода, сумму, дату и время, потенциального получателя и описание;
- Изучить и использовать docker и docker-compose;
- Написать unit/интеграционные тесты;
- Выполнить нагрузочное тестирование микросервиса;
- Написать генератор рандомных записей для roll-up таблицы;
- Реализовать методы резервирования, разрезервирования средств и признания выручки;
- Реализовать метод составления финансового месячного отчета;
- Написать файловый сервер для предоставления ссылки на директорию с CSV файлом;
Для работы с базой данных была выбрана реляционная СУБД PostgreSQL. Средсвта пользователя хранятся представлены целочисленным значением (в копейках). Изначально такой формат был выбран для работы с балансом пользователя с целью избежать округления. В дальнейшем, для работы с финансами, используется тип с фиксированной точкой Decimal, что позволяет избавиться от необходимости хранить значения в целочисленном формате.
Для решения проблемы пересчета баланса пользователей использовалось материализованное или обычное представление, которое можно обновлять в случае необходимости. Однако при большом количестве записей скорость и эффективность данного способа снижалась, из-за постоянного пересчета всех записей из основной таблицы. На замену была выбрана roll-up таблица, которая вставляет новую строку и обновляет существующие строки в случае конфликта ограничений. Данный вариант требует меньшее количество запросов, а так же не требует постоянного пересчета записей.
posting:
tx_id | amount | account_id | type | addressee |
---|---|---|---|---|
1 | 1000 | 2 | deposit | null |
2 | -1000 | 0 | deposit | null |
3 | 1000 | 1 | deposit | null |
4 | -1000 | 0 | deposit | null |
5 | -100 | 2 | withdrawal | null |
6 | 100 | 0 | withdrawal | null |
7 | -100 | 1 | transfer | 3 |
8 | 100 | 3 | transfer | 1 |
9 | -100 | 3 | withdrawal | null |
10 | 100 | 0 | withdrawal | null |
11 | -100 | 1 | withdrawal | null |
11 | 100 | 0 | withdrawal | null |
balances с roll-up:
перед выполнением операции tx_id = 3
account_id | balance | last_tx_id |
---|---|---|
1 | 1000 -> 800 | 3 -> 11 |
2 | 900 | 5 |
3 | 100 | 8 |
Итог: 2 обновления
balances без roll-up:
перед выполнением операции tx_id = 3
account_id | balance |
---|---|
1 | 1000 -> 900 -> 800 |
2 | 100 -> 900 |
3 | 100 -> 0 |
Итог: 4 обновления
Так же, для проверки эффективности работы roll-up таблицы, были проведены нагрузочные тесты.
На графике зависимости задержки (в миллисекундах) от процентиля наглядно предоставлены преимущества работы roll-up таблицы над своими конкурентами.
Для более подробного ознакомления - https://stefan-poeltl.medium.com/views-v-s-materialized-views-v-s-rollup-tables-with-postgresql-2b3824b45330
Тип с фиксированной точкой Decimal позволяет работать с дяситичными и целочисленными значениями. Данный тип был вабран для работы с финансами, чтобы избежать окргуления и потери "лишней" копейки.
- Нулевое значение равно 0, и его можно безопасно использовать без инициализации;
- Сложение, вычитание, умножение без потери точности;
- Деление с заданной точностью;
- Сериализация/десериализация базы данных/sql;
Ссылка на подробную документацию типа Decimal - https://pkg.go.dev/github.com/shopspring/decimal
Для работы с банковским счетом пользователя был выбран способ двойной бухгалтерской записи. Особенность данного способа заключается в том, что в системе с "двоичной записью" каждое значение записывается дважды - как кредит и дебет (положительное и отрицательное значение).
- Каждая запись в системе должна быть сбалансированной, т.е. сумма всех значений в рамках одной операции должна давать ноль;
- Сумма всех значений во всей системе в любой момент времени должна давать ноль (правило т.н. "пробного баланса");
- Уже занесенные в БД значения нельзя редактировать или удалять. При необходимости исправлений операция сперва должна быть отменена другой операцией с противоположным знаком, а затем повторена с правильным значением. Это позволяет реализовать надежный аудиторский след (полный лог всех транзакций, часто требуемый при проверках);
- Отсутствие возможности редактирования и удаления записей, что позволяет контролировать историю записей, не боясь каких либо изменений извне;
- Возможность построить очень комплексные системы контроля ценностями;
- Контьроль всей истории транзакций, возможность разбить все записи по периодам (месяц, год, рабочий квартал);
Для более подробного ознакомления предоставляю ссылки на статьи:
- https://habr.com/ru/post/480394/
- https://www.balanced.software/double-entry-bookkeeping-for-programmers/
По правилам бухгалтерии резервирование средств производится на 97-й счет (расходы будущих периодов). За резервный 97-й счет был взят account_id = 1. При покупке услуги пользователем деньги переводятся на нулевой аккаунт (внутри метода Reservation вызывается вложенный метод Transfer), запись о переводе добавляется в основную таблицу posting. В таблице deferred_expenses (отложенные покупки) фиксируется запись о покупке услуги со статусом 'reservation'.
При невыполнении услуги (отсутствие записи выполненной услуги в таблице consolidated_report) или ее отмене производится разрезервирование средств с резервного счета на счет пользователя. Запись о переводе средств фиксируется в основной таблицу posting (внутри метода Unreservation вызывается вложенный метод Transfer), в таблице deferred_expenses (отложенные покупки) фиксируется запись о разрезервации со статусом 'unreservation'.
При выполнении услуги (отсутствие записи о разрезервировании средств в таблице deferred_expenses) компания переводит деньги за ее выполнение с резервного счета на счет компании (account_id = 0). Может производиться как частичное, так и полное снятие средств за выполненную услугу, в зависимости от условий. Например, компания сама предоставляет выполнение услуги или через посредника. Во втором случае компания снимает только свой процент, а оставшиеся деньги остаются в резерве для дальнейшего перевода посреднику. Запись о переводе средств фиксируется в основной таблицу posting (внутри метода Revenue вызывается вложенный метод Transfer), в таблице consolidated_report (сводный отчет) фиксируется запись о начислении денег на счет компании.
В данной реализации была выбрана общая система налогообложения (выручка компании считается по факту выполненных работ).
При формировании месячного отчета происходит считывание данных из таблицы consolidated_report (сводный отчет) и подсчет общей выручки для каждой выполненной услуги. Файловый сервер предоставляет ссылку на директорию, где месячный отчет в формате CSV.
Для удобных нагрузочных и интеграционных тестов был написан генератор записей для roll-up таблицы.
Пример запуска:
go run .go 100 200000
На вход дается количество пользователей (id пользователя от 2 до n) и записей (суммарное колличество записей, добавляемых в таблицу);
- Убедитесь, что у вас самые последние образы контейнеров Docker:
docker-compose -f ./deployments/docker-compose.yaml pull
- Запустите службу в локальном Docker:
docker-compose -f ./deployments/docker-compose.yaml up
Для выполнения запросов к сервису использовался HTTP-клиент Postman;
- deposit:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/deposit
; - Пример запроса:
{"User_id":2, "Amount":1000}
- withdrawal:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/withdrawal
; - Пример запроса:
{"User_id":2, "Amount":1000, "Description":"test"}
- transfer:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/transf
; - Пример запроса:
{"Sender":2, "Recipient":2, "Amount":1000, "Description":"test"}
- readUser:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/read
; - Пример запроса:
{"User_id":2, "Currency":"EUR"} или {"User_id":2}
- readUserHistory:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/history
; - Пример запроса:
{"User_id":2, "order":"date", "limit":100, "offset":0}
- reservationOfFunds:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/reserve
; - Пример запроса:
{user_id":2, "service_id":2, "order_id":2, "price":100}
- unreservationOfFunds:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/unreserve
; - Пример запроса:
{user_id":2, "service_id":2, "order_id":2}
- RevenueRecognition:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/revenue
; - Пример запроса:
{"user_id":2, "service_id":2, "order_id":2, "sum":100.00}
- MonthlyReport:
- тип запроса:
POST
; - URL запроса:
http://localhost:9090/report
; - Пример запроса:
{"year":2022, "month":10}
- Получение баланса пользователя из таблицы с двойной записью;
- Для получения баланса решено было использовать Roll-up таблицу;
- Генерация объемного количества данных в таблице для тестирования, sql запрос не мог генерировать большое количество данных за раз, но возвращал положительный ответ;
- Был написан отдельный генератор значений на go;
- Корректная работа с docker в wsl 2. Проблема запуска docker на системе windows 10 pro;
- Первые коммиты не имели в себе нормального описания;
- Было принято решение не трогать старые коммиты, так как их изменения могли привести к проблемам. Последующие коммиты имеют в себе полное описание изменений в проекте;
- Проблема с уровнем изоляции транзакций postgres, при параллельном выполнении несколько сериализуемых транзакций, результат фиксации успешной транзакции оказывается несогласованным (аномалия сериализации);
- При начале транзакции устанавливается уровень изоляции транзакции - Serializable (Сериализуемость), при котором невозможно "грязное чтение", неповторяемое чтение, фантомное чтение и аномалия сериализации. Данное ограничение имеет свое влияние на производительность приложения, но оно не критично, при условии возможности конкурентно выполнять несколько сериализуемых транзакций;
- Ошибка с вложенными транзакциями. При выполнении метода разрезервирования или получения выручки при возникновении ошибки, записи из основной таблицы posting не удалялись;
- Причиной ошибки был вложенный метод Transfer, вызов которого происходил вне транзакции. Были прописаны и учтены условия вызова метода в и вне транзакции.
- При выполении методов разрезервирования или признания выручки иногда может возникать зависание запроса (зпрос не выполняется);
- На данный момент проблема решается. Предположительно проблема связаны с завершениями транзакции. Вторая транзакция пытается приступить к выполнению, пока вторая еще не закончила свое выполнение;
- (Недочет) При записи CSV файла с отчетом пользователь может получить устаревший отчет если, при выполнении метода MonthlyReport была произведена покупка. При повторном запросе на получение отчета новый отчет перепишет старый.
- Можно использовать полную дату в имени файла или 1-й байт хеш-суммы