diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..854a0b7
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,10 @@
+name: Build Maven Artifact
+
+on:
+ pull_request:
+ branches:
+ - '**'
+
+jobs:
+ build:
+ uses: valitydev/java-workflow/.github/workflows/maven-service-build.yml@v4
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..ca85344
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,15 @@
+name: Deploy Docker Image
+
+on:
+ push:
+ branches:
+ - 'master'
+ - 'main'
+ - 'epic/**'
+
+jobs:
+ build-and-deploy:
+ uses: valitydev/java-workflow/.github/workflows/maven-service-deploy.yml@v4
+ secrets:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..44b19e6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,87 @@
+# Created by .ignore support plugin (hsz.mobi)
+### Maven template
+target/
+temp_context/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+### JetBrains template
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff:
+.idea/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+
+# Sensitive or high-churn files:
+.idea/dataSources.ids
+.idea/dataSources.xml
+.idea/dataSources.local.xml
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+
+# Gradle:
+.idea/gradle.xml
+.idea/libraries
+
+# Mongo Explorer plugin:
+.idea/mongoSettings.xml
+
+## File-based project format:
+*.iws
+*.ipr
+*.iml
+
+## Plugin-specific files:
+
+# IntelliJ
+/out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+### Java template
+*.class
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.ear
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+
+env.list
+
+
+# OSX
+*.DS_Store
+.AppleDouble
+.LSOverride
+
+# TestContainers
+.testcontainers-*
+
+refs/
+research/
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 0000000..a2af2fa
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1 @@
+* @valitydev/java
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d9a10c0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,176 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4016db6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,79 @@
+# CC Reporter
+
+`cc-reporter` асинхронно строит CSV-отчёты по платежам и выводам. Сервис читает доменные события из Kafka,
+поддерживает в PostgreSQL актуальное состояние транзакций и формирует отчёт по согласованному снимку данных.
+
+Жизненный цикл задания:
+
+```text
+pending -> processing -> created -> expired
+ | | |
+ | | +-> failed / timed_out
+ | +----> pending (retry)
+ +----------------> canceled
+```
+
+Готовый файл хранится во внешнем файловом хранилище и выдаётся по временной подписанной ссылке.
+
+## CSV
+
+Формат одинаков для всех отчётов:
+
+| Параметр | Значение |
+|---|---|
+| Кодировка | UTF-8 |
+| Разделитель | `,` |
+| Конец строки | CRLF |
+| Экранирование | RFC 4180 |
+| `null` | Пустое поле |
+| Дата | `yyyy-MM-dd` |
+| Время | `HH:mm:ss` |
+| Timezone | `CreateReportRequest.timezone`, по умолчанию UTC |
+| Денежные значения | Decimal по экспоненте соответствующей валюты |
+| `exchange_rate_internal` | Decimal без экспоненциальной записи |
+
+`finalized_date` и `finalized_time` соответствуют текущему терминальному статусу. Если более новое событие
+корректирует терминальный статус, время финализации также обновляется.
+
+### Payments
+
+```csv
+created_date,created_time,finalized_date,finalized_time,invoice_id,payment_id,status,amount,currency,trx_id,provider_id,terminal_id,shop_id,exchange_rate_internal,provider_amount,provider_currency,original_amount,original_currency,converted_amount
+2026-08-20,10:15:00,2026-08-20,10:15:04,invoice-1,payment-1,captured,1000.00,RUB,trx-1,12,34,shop-1,1.0000000000,1000.00,RUB,1000.00,RUB,1000.00
+```
+
+| CSV-поле | Источник |
+|---|---|
+| `created_date`, `created_time` | `payment_txn_current.created_at` |
+| `finalized_date`, `finalized_time` | `payment_txn_current.finalized_at` |
+| `invoice_id`, `payment_id`, `status` | `payment_txn_current` |
+| `amount`, `currency` | `payment_txn_current` |
+| `trx_id` | `payment_txn_current.trx_id` |
+| `provider_id`, `terminal_id`, `shop_id` | `payment_txn_current` |
+| `exchange_rate_internal` | `payment_txn_current.exchange_rate_internal` |
+| `provider_amount`, `provider_currency` | `payment_txn_current` |
+| `original_amount`, `original_currency`, `converted_amount` | `payment_txn_current` |
+
+### Withdrawals
+
+```csv
+created_date,created_time,finalized_date,finalized_time,withdrawal_id,status,amount,currency,trx_id,provider_id,terminal_id,wallet_id,exchange_rate_internal,provider_amount,provider_currency,original_amount,original_currency,converted_amount
+2026-08-20,11:20:00,2026-08-20,11:20:05,withdrawal-1,succeeded,5000.00,RUB,trx-2,12,34,wallet-1,83.3333333333,5000.00,RUB,60.00,USD,5000.00
+```
+
+| CSV-поле | Источник |
+|---|---|
+| `created_date`, `created_time` | `withdrawal_txn_current.created_at` |
+| `finalized_date`, `finalized_time` | `withdrawal_txn_current.finalized_at` |
+| `withdrawal_id`, `status` | `withdrawal_txn_current` |
+| `amount`, `currency` | `withdrawal_txn_current`; обновляются при `body_changed` |
+| `trx_id` | последняя `withdrawal_session` |
+| `provider_id`, `terminal_id`, `wallet_id` | `withdrawal_txn_current` |
+| `exchange_rate_internal` | `withdrawal_txn_current.exchange_rate_internal` |
+| `provider_amount`, `provider_currency` | `withdrawal_txn_current` |
+| `original_amount`, `original_currency`, `converted_amount` | `withdrawal_txn_current` |
+
+## Документация
+
+- [Модель данных, ingestion и жизненный цикл](docs/docs.md)
+- [Требования и архитектурные решения](docs/PLAN.md)
diff --git a/docs/PLAN.md b/docs/PLAN.md
new file mode 100644
index 0000000..6e3dfe0
--- /dev/null
+++ b/docs/PLAN.md
@@ -0,0 +1,376 @@
+# CC Reporter
+
+## 1. Зачем нужен отдельный сервис
+
+Текущий подход с предварительной загрузкой данных на стороне интерфейса не масштабируется:
+
+1. CSV собирается в памяти браузера, и на длинных периодах это заметно замедляет работу.
+2. Если в результате больше 1000 строк, пользователю приходится многократно нажимать `preload` и `fetch`.
+3. Долгие выборки в момент выгрузки создают лишнюю нагрузку на `magista` и `fistful-magista`.
+4. Для полугодовых и годовых выгрузок нельзя гарантировать предсказуемое время подготовки в рамках SLA.
+5. Требования задач к составу CSV (`trx_id`, `currency`, FX, `finalized_time`, корректный `amount`) требуют, чтобы отчет формировался в управляемом серверном процессе.
+
+Вывод: длинные транзакционные отчеты нужно формировать не в клиентской части, а в отдельном асинхронном сервисе на стороне сервера. Да, это сложнее в реализации, но именно такой подход соответствует задаче годовых отчетов, изолирует нагрузку и снижает зависимость от внешних поисковых API.
+
+## 2. Цель и рамки решения
+
+### Цель
+
+Сделать сервис, пригодный для промышленной эксплуатации, который:
+
+1. Асинхронно формирует CSV-отчеты по `payments` и `withdrawals`.
+2. Поддерживает длинные периоды, включая годовые.
+3. Работает на собственной модели чтения в PostgreSQL.
+4. Выдает готовый файл по временной подписанной ссылке.
+
+### Что входит в первую версию
+
+1. Жизненный цикл отчета:
+ `pending -> processing -> created -> expired`,
+ `processing -> pending | failed | timed_out | canceled`,
+ `pending -> canceled`.
+2. Загрузка данных из Kafka в таблицы актуального состояния сущностей по платежам и выплатам.
+3. API на Thrift для клиентской (фронт) части.
+4. Фоновый обработчик и планировщик для построения отчетов.
+5. Публикация CSV в S3-совместимое хранилище.
+
+### Что не входит в первую версию
+
+1. Генерация CSV через поисковые API `magista` и `fistful-magista` в момент запроса.
+2. Хранение полной истории событий по платежам и выплатам в БД `CCR`.
+3. Внутренняя аутентификация сервиса: ее обеспечивает `Wachter`.
+4. Агрегированные аналитические отчеты и произвольные аналитические срезы.
+
+## 3. Загрузка из Kafka и модель чтения
+
+### 3.1 Общие правила приема данных
+
+1. `CCR` читает Kafka пакетами.
+2. Каждый пакет обрабатывается в рамках одной транзакции БД.
+3. Подтверждение чтения в Kafka выполняется только после успешного `commit`.
+4. В базе хранится только актуальное состояние сущности, а не полный журнал событий.
+5. Для каждой сущности используется `upsert`: запись создается при первом событии, а затем обновляется только если пришло более новое событие (по `MachineEvent.eventId`). Повторные и более старые события ничего не меняют.
+6. Повторное чтение Kafka должно быть безопасным: оно не создает дубликаты и не откатывает состояние назад.
+
+### 3.2 Почему выбран именно этот подход
+
+1. Для длинных CSV-выгрузок важнее быстро читать актуальное состояние по фильтрам, чем хранить внутри `CCR` полную историю событий.
+2. Модель актуального состояния уменьшает объем хранения и упрощает индексацию под реальные фильтры интерфейса.
+3. Такой способ загрузки хорошо сочетается с асинхронной генерацией отчетов: данные сначала приводятся к удобному виду, а затем уже используются при построении файла.
+
+### 3.3 Lookup-данные из Dominant
+
+1. Поля `shop_name`, `wallet_name`, `provider_name`, `terminal_name` загружаются не из транзакционных Kafka-topic'ов
+ payments/withdrawals, а из отдельного Kafka-потока `Dominant`.
+2. `CCR` читает коммиты `HistoricalCommit` из `Dominant` и материализует display-name lookup-таблицы:
+ - `shop_lookup`
+ - `wallet_lookup`
+ - `provider_lookup`
+ - `terminal_lookup`
+3. Для `insert` и `update` в lookup-таблицах сохраняется актуальное имя сущности и `dominant_version_id`.
+4. Для `remove` сохраняется tombstone-состояние (`deleted = true`), чтобы более старый commit не мог вернуть устаревшее
+ имя.
+5. Эти таблицы используются при поиске по имени и идентификатору во время построения отчёта.
+ Имена сущностей не входят в бизнес-ключи транзакционных таблиц актуального состояния.
+
+### 3.4 Актуальное состояние `payments`
+
+1. Таблица: `payment_txn_current`.
+2. Бизнес-ключ: `(invoice_id, payment_id)`.
+3. Порядок событий в домене: `MachineEvent.eventId`.
+4. Контракт `upsert`:
+ - `INSERT ... ON CONFLICT (invoice_id, payment_id) DO UPDATE`
+ - `... WHERE payment_txn_current.domain_event_id < EXCLUDED.domain_event_id`
+5. При событии смены статуса `finalized_at` синхронизируется с новым статусом:
+ - для терминального статуса записывается время этого status-event;
+ - для нетерминального статуса поле очищается;
+ - события, которые статус не меняют, `finalized_at` не затрагивают.
+ Это позволяет корректно обработать в том числе смену одного финального статуса на другой после корректировки.
+
+### 3.5 Актуальное состояние `withdrawals`
+
+1. Таблица: `withdrawal_txn_current`.
+2. Бизнес-ключ: `withdrawal_id`.
+3. Порядок событий в домене: `MachineEvent.eventId`.
+4. Контракт `upsert`:
+ - `INSERT ... ON CONFLICT (withdrawal_id) DO UPDATE`
+ - `... WHERE withdrawal_txn_current.domain_event_id < EXCLUDED.domain_event_id`
+5. Логика обновления такая же: по каждому бизнес-ключу сохраняется только самое новое состояние.
+
+### 3.6 Повторное чтение и восстановление после сбоев
+
+1. Для повторного чтения достаточно перезапустить процесс чтения с нужной политикой `offset` или использовать отдельную `consumer group`.
+2. Идемпотентный `upsert` по бизнес-ключу и доменному `event_id` делает такое повторное чтение безопасным.
+
+## 4. Требования
+
+### 4.1 Бизнес-требования
+
+#### Первоначальные сырые требования
+Создание CSV-отчета на бекенде
+
+Требуется реализовать функционал по загрузке отчета с бэкенда. Отчет - потранзакционная CSV, с учетом фильтров, которые были переданы в запросе. В обычном сценарии это выгрузка за месяц, но может быть и дольше (несколько месяцев, полгода). Доп.детали расписаны в эпике к этой задаче.
+
+Сейчас control-center создает отчет на своей стороне, используя выгрузку транзакций, что отдала магиста. Причем на стороне магисты есть ограничение в 1000 записей. Большее количество записей необходимо подгружать отдельной кнопкой.
+
+Изначальный эпик задачи:
+
+Внести доработки в разделы payments и withdrawals:
+
+Добавить данные по:
+итоговая сумма в определенной валюте?
+Trx ID - идентификатор транзакции со стороны провайдера
+Сконвертированная сумма (на транзакциях с конвертацией отображать также сумму и валюту оригинальной транзакции)
+Время финализации
+Добавить в фильтры выпадающие списки с мультиселектом:
+Provider name
+Shop name
+Wallet name
+Terminal name
+Trx ID - идентификатор транзакции со стороны провайдера
+Сurrency
+Возможность добавлять точное время в разрезе часов/минут
+При внесении значений в поля фильтров происходил поиск по части наименования и/или по ID запрашиваемого шопа/терминала, убрать привязку к верхнему / нижнему регистру
+
+Добавить возможность предзагрузить все транзакции (не кликать Preload по +1000 транзакций) и Перенести выгрузку csv на бекенд
+
+В формате выгружаемого файла нужно
+
+Пофиксить Проблему, при которой при выгрузке транзакций значение в поле Amount имеет некорректное значение (пример 2 000,00 ₸ на валюте Тенге)
+Разделить дату и время на отдельные столбцы
+Добавить столбцы выгрузки trx_id (идентификатор транзакции со стороны провайдера) и Currency
+Для обработки транзакции через валютные каналы, добавить столбцы Курсов с нашей стороны и фактическая сумма в валюте передаваемая провайдеру
+
+#### Формализированные требования
+
+1. Отчеты должны поддерживать `payments` и `withdrawals`.
+2. Выгрузка строится на стороне бэкенда, а не в браузере.
+3. Поддерживаются длинные периоды, включая 12 месяцев.
+4. Должны работать фильтры интерфейса:
+ - `Provider`
+ - `Shop`
+ - `Wallet`
+ - `Terminal`
+ - `trx_id`
+ - `Currency`
+ - `Status`
+ - `time range` с точностью до часов и минут
+5. Поиск по части имени и/или ID должен работать без учета регистра.
+ Имена `shop`, `wallet`, `provider`, `terminal` для такого поиска берутся из lookup-таблиц, наполняемых из `Dominant`.
+6. В CSV обязательно должны быть:
+ - `trx_id`
+ - `currency`
+ - отдельные колонки `date` и `time`
+ - `finalized_time`
+ - блок валютного пересчета (`original_amount`, `original_currency`, `converted_amount`, `exchange_rate_internal`, `provider_amount`)
+7. Форматирование `amount` должно корректно учитывать `exponent` валюты.
+8. Тип отчета должен состоять из бизнес-типа (`payments`/`withdrawals`) и типа файла (`csv`).
+9. Сейчас одному отчету соответствует ровно один итоговый файл.
+
+### 4.2 Технические требования
+
+1. Источник данных: Kafka, по тому же принципу, что и в текущих доменных сервисах.
+2. Процесс чтения подтверждает `offset` только после успешного `commit` транзакции БД по всему пакету.
+3. Повторное чтение `topic` должно быть безопасным и не приводить к дубликатам или откату актуального состояния.
+4. `CreateReport` должен поддерживать идемпотентность по `(created_by, idempotency_key)`. (`created_by` это идентификатор субъекта из jwt токена который приходит в `Wachter`, `idempotency_key` uid с фронтенда)
+5. Нужен управляемый механизм восстановления для повторных попыток и зависших заданий.
+6. Нужны индексы под реальные фильтры интерфейса и длинные диапазоны.
+7. При ошибке генерации не должно оставаться поврежденных локальных артефактов.
+
+## 5. Архитектура и жизненный цикл отчета
+
+### 5.1 Компоненты
+
+1. `Control Center Frontend`
+2. `Wachter` (аутентификация, авторизация и маршрутизация по JWT)
+3. `CC Reporter API` (Thrift)
+4. `CC Reporter Scheduler`: обработчик и планировщик
+5. `CC Reporter Kafka Listener`: процессы чтения Kafka
+6. `PostgreSQL` (актуальное состояние и жизненный цикл отчетов)
+7. `Minio`: S3-совместимое хранилище
+
+### 5.2 Сквозной сценарий
+
+1. Пользователь задает фильтры и нажимает `Download report`.
+2. Клиентская часть вызывает `CreateReport`.
+3. API проверяет соответствие пары `report_type + file_type` и ветки `query`, сохраняет `report_job(status = pending)` и возвращает `report_id`.
+4. Планировщик атомарно забирает до `report.worker-concurrency` заданий со статусом `pending`, переводит их в
+ `processing`, увеличивает `attempt` и запускает ограниченным пулом worker-ов.
+5. Обработчик открывает отдельную транзакцию `READ ONLY REPEATABLE READ` для чтения данных отчета.
+6. Сразу после открытия транзакции он фиксирует `data_snapshot_fixed_at = transaction_timestamp()`.
+7. `started_at` отражает старт текущей попытки worker-а, а `data_snapshot_fixed_at` — момент фиксации MVCC-снимка данных
+ для всего отчета.
+8. Внутри этой транзакции обработчик потоково читает актуальное состояние через серверный курсор, порциями.
+9. CSV записывается во временный артефакт:
+ - локальный временный файл или
+ - временный ключ объекта в хранилище, который не публикуется во внешнем API
+10. После полной записи файл хешируется, загружается в итоговый ключ объекта, затем создается `report_file` (одна запись на один `report_job`).
+11. Только после успешной публикации файла задание завершается в статусе `created`.
+12. Если возникает ошибка, обработчик удаляет временный файл или объект и не создает `report_file`.
+13. При временной ошибке задание возвращается в `pending`; `next_attempt_at` рассчитывается от фактического времени
+ ошибки.
+14. Каждая попытка имеет hard timeout. По deadline worker task сначала отменяется через interrupt, затем отчёт условно
+ переводится из `processing` в `timed_out`.
+15. Stale cleanup переводит оставшиеся `processing` в `timed_out` после остановки процесса или потери instance.
+16. Все конкурирующие переходы содержат предикат исходного статуса. Поздний worker не может переписать `canceled`,
+ `timed_out` или другой терминальный статус.
+17. SQL генерации ограничивается локальным PostgreSQL `statement_timeout`. JDBC connect, socket read и cancel signal
+ имеют отдельные таймауты; TCP keepalive включён.
+18. Клиентская часть получает статусы через `GetReports` и `GetReport`.
+19. Для скачивания клиентская часть вызывает `GeneratePresignedUrl` с `file_id` и получает ссылку, для которой TTL
+ принудительно ограничивается на стороне сервиса.
+
+### 5.3 Согласованность данных при генерации
+
+`CCR` гарантирует два уровня фиксации:
+
+1. Логическое окно данных задается `time_range` внутри сохраненного `query_json`; отдельные дублирующие колонки не
+ нужны.
+2. Физическая согласованность чтения обеспечивается транзакцией `READ ONLY REPEATABLE READ` на все время построения файла.
+
+Это означает:
+
+1. Во время одной генерации параллельные обновления актуального состояния не попадут в тот же отчет частично.
+2. Отчет представляет собой согласованный снимок на момент начала `processing`, а не на момент вызова `CreateReport`.
+
+## 6. Связанные спецификации
+
+1. SQL DDL
+2. Thrift API
+3. Формат CSV в `README.md`
+
+## 7. Сценарий в админке
+
+1. На страницах `payments` и `withdrawals` пользователь задает фильтры и нажимает `Download report`.
+2. Интерфейс показывает встроенный статус или всплывающее уведомление: отчет поставлен в очередь.
+3. Во вкладке `Reports` отображается таблица:
+ - `type`
+ - `period`
+ - `created_at`
+ - `status`
+ - `rows_count`
+ - `actions`
+4. Для `period` интерфейс использует `query.time_range`, который возвращается в `Report`.
+5. Поддерживаются статусы:
+ - `pending`
+ - `processing`
+ - `created`
+ - `failed`
+ - `timed_out` (таймаут так чисто показать определенность ошибки зависшие таски (строились настолько долго что вышли за рамки sla ожиданий) , это пробрасывается дофронта, там выводится таймаут и кнопка retry)
+ - `canceled`
+ - `expired`
+6. Для `created` доступна кнопка `Download`.
+7. Для `pending` и `processing` доступна `Cancel`.
+8. Для `failed` и `timed_out` доступна `Retry` через повторный `CreateReport` с тем же `query`.
+9. Список отчетов загружается постранично через `continuation_token`.
+
+## 8. Риски и меры снижения
+
+### 8.1 Рост объема хранения
+
+Риск:
+
+1. Со временем сильно вырастут и таблицы актуального состояния, и архив файлов.
+
+Меры снижения:
+
+1. Настроить сроки хранения для `report_job` и `report_file`.
+2. Настроить политику жизненного цикла объектов в S3 с ограничением по TTL.
+3. Регулярно выполнять `VACUUM/ANALYZE`.
+4. Контролировать разрастание таблиц и смотреть `EXPLAIN` по самым тяжелым запросам.
+5. Ограничить количество одновременно запущенных больших отчетов на одного пользователя.
+
+### 8.2 Долгая генерация больших отчетов
+
+Риск:
+
+1. Годовые отчеты могут строиться несколько минут и задерживать всю очередь.
+
+Меры снижения:
+
+1. Использовать потоковую запись без накопления всего файла в памяти.
+2. Читать данные через серверный курсор, порциями.
+3. Использовать ограниченный пул обработчиков; число worker-ов задаётся через `report.worker-concurrency`.
+4. Настроить политику повторных попыток через `next_attempt_at`.
+5. Ограничивать каждую попытку hard timeout и сохранять stale cleanup как восстановление после потери процесса.
+6. Поддерживать `spring.datasource.hikari.maximum-pool-size >= worker-concurrency + 2`; для смешанной нагрузки
+ использовать резерв `+4`. Соединения сверх worker pool нужны для переходов статусов и остальных транзакций
+ instance.
+
+### 8.3 Позднее дозаполнение (`trx_id`, FX, данные провайдера)
+
+Риск:
+
+1. Часть полей может приходить не в первом событии.
+
+Меры снижения:
+
+1. Модель актуального состояния допускает частичное заполнение с последующим объединением данных.
+2. Для `payments` FX-блока (`original_amount`, `original_currency`, `converted_amount`, `exchange_rate_internal`, `provider_amount`, `provider_currency`) на первом проходе реализации допускается временное заполнение mock-значениями с явной пометкой `TODO`, пока не будет подтвержден окончательный источник этих полей.
+3. Это временное решение нужно трактовать именно как проектную заглушку, а не как финальный бизнес-контракт или окончательную модель хранения.
+4. Отчет всегда строится по последнему актуальному состоянию на момент `data_snapshot_fixed_at`.
+
+### 8.4 Долгие транзакции `REPEATABLE READ`
+
+Риск:
+
+1. Долгая транзакция чтения может слишком долго удерживать MVCC-снимок и увеличивать нагрузку на `autovacuum`.
+
+Меры снижения:
+
+1. Ограничить число одновременно строящихся длинных отчетов.
+2. Использовать только потоковое чтение, без помещения всего набора результатов в память.
+3. Ограничивать построение общим hard timeout и PostgreSQL `statement_timeout`.
+4. Ограничивать JDBC connect, socket read и cancel signal; включать TCP keepalive.
+5. Проводить нагрузочное тестирование именно для длительных сценариев с `REPEATABLE READ`.
+
+### 8.5 Ограничение модели актуального состояния
+
+Риск:
+
+1. Если задание долго ждет в очереди, отчет отражает согласованное состояние на момент начала `processing`, а не на момент нажатия кнопки.
+
+Меры снижения:
+
+1. Минимизировать задержку до старта обработчика.
+2. Не держать очередь длинной без необходимости.
+3. Если бизнесу критично строгое состояние именно на момент запроса, вынести это в отдельное следующее требование.
+
+## 9. Неуточненные вопросы в текущем пакете
+
+
+## 10. Безопасность
+
+1. Аутентификация и авторизация внутри `CCR` не реализуются: это зона ответственности `Wachter`.
+2. Доступ к файлам дается только через `presigned URL` с ограниченным TTL.
+3. После выдачи `presigned URL` сервис уже не контролирует дальнейшее распространение ссылки.
+
+Вопросы для согласования правил доступа:
+
+1. Какой максимальный TTL допустим по внутренней политике.
+2. Нужен ли режим однократного скачивания.
+3. Нужен ли доп аудит помимо предложенного аудита скачиваний `report_audit_event` (см sql).
+
+Ответы:
+10-15 минут кажется будет достаточно чтобы сотрудник выгрузил себе файл
+можно включить. не помешает даже при минимальном ttl
+достаточно логировать генерацию и дальнейшие все обращения по ссылке. (userid, ttl, ip, время при генерации. ip, время, ua при обращении к ссылке. это надо на s3 вероятно настроить, может с помощью девопс команды)
+
+## 11. План реализации
+
+1. Этап 1: процессы чтения Kafka, таблицы актуального состояния и идемпотентный `upsert` по `domain_event_id`.
+2. Этап 2: API на Thrift и таблицы `report_job`, `report_file`, `report_audit_event`.
+3. Этап 3: обработчик и планировщик, повторные попытки, обработка таймаутов и атомарная публикация файла.
+4. Этап 4: интеграция с клиентской частью (`Reports`, статусы, повторный запуск, пагинация).
+5. Этап 5: нагрузочные тесты для длинных периодов, включая длительное чтение в `REPEATABLE READ`.
+
+## 12. Критерии готовности
+
+1. Клиентская часть больше не делает предварительную загрузку по 1000 строк для CSV.
+2. `payments` и `withdrawals` формируются через асинхронный серверный жизненный цикл.
+3. Повторное чтение Kafka не создает дубликаты и не откатывает актуальное состояние назад.
+4. Во время генерации параллельные обновления не смешиваются в одном отчете.
+5. Поврежденные временные артефакты не публикуются наружу.
+6. CSV соответствует обязательным полям по требованиям.
+7. Несколько worker-ов не забирают один отчёт повторно и не превышают настроенный предел параллелизма.
+8. Поздний worker не перезаписывает `canceled`, `timed_out`, `failed` или `expired`.
diff --git a/docs/docs.md b/docs/docs.md
new file mode 100644
index 0000000..4be01ee
--- /dev/null
+++ b/docs/docs.md
@@ -0,0 +1,88 @@
+# Устройство CC Reporter
+
+## Жизненный цикл отчёта
+
+```text
+ ┌── временная ошибка ──> pending(next_attempt_at)
+ │
+pending ── claim ──> processing ──────┼── успех ──> created ── TTL ──> expired
+ │ │
+ └── cancel ──> canceled ├── закончились попытки ──> failed
+ ├── hard timeout ──> timed_out
+ └── cancel ──> canceled
+```
+
+Переход `pending -> processing` выполняется атомарно. DAO выбирает только задания, для которых наступил
+`next_attempt_at`, блокирует строку через `FOR UPDATE SKIP LOCKED`, увеличивает `attempt`, записывает `started_at`
+и очищает ошибку предыдущей попытки. Поэтому несколько экземпляров сервиса могут разбирать одну очередь без
+двойной обработки одного задания.
+
+Число одновременно выполняемых отчётов задаёт `report.worker-concurrency`. Одна попытка ограничена
+`report.processing-timeout-ms`. При превышении лимита worker получает interrupt, а запись условно переводится
+из `processing` в `timed_out`. Позднее завершение worker не может перезаписать уже установленный терминальный статус.
+
+При временной ошибке отчёт возвращается в `pending` с новым `next_attempt_at`. После исчерпания попыток он
+переходит в `failed`. Завершение отчёта и добавление `report_file` выполняются в одной транзакции.
+
+Scheduler переводит готовые отчёты в `expired` после `expires_at`. `GetReport` и `GetReports` перед чтением также
+выполняют идемпотентное истечение просроченных `created`-отчётов, поэтому корректность API не зависит от точности
+срабатывания фонового scheduler. `GeneratePresignedUrl` дополнительно разрешает скачивание только пока отчёт
+не просрочен.
+
+## Согласованность current-state
+
+`payment_txn_current` и `withdrawal_txn_current` содержат не историю, а последнее известное состояние сущности.
+Обновление принимается только если `MachineEvent.eventId` больше уже сохранённого. Повторные и более старые события
+не откатывают состояние назад.
+
+Событие смены статуса является авторитетным для полей, которые зависят от статуса:
+
+- `status` заменяется значением более нового status-event;
+- для терминального статуса `finalized_at` становится временем этого события;
+- если новый статус нетерминальный, `finalized_at` очищается;
+- `error_summary` заменяется значением нового status-event и очищается, если новая ошибка отсутствует.
+
+События, которые статус не меняют, эти поля сохраняют. Это важно для корректировок, меняющих один финальный статус
+на другой.
+
+## Payments
+
+`PaymentEventProjector` обрабатывает изменения платежа по порядку внутри `MachineEvent`.
+
+- `InvoicePaymentStarted` задаёт исходные идентификаторы, сумму, валюту, маршрут и статус `pending`.
+- `InvoicePaymentRouteChanged` обновляет provider/terminal.
+- `InvoicePaymentCashChanged` обновляет `amount` и `currency`.
+- `InvoicePaymentCashFlowChanged` пересчитывает `amount` и `fee` через `CashFlowAmountExtractor`.
+- `InvoicePaymentStatusChanged` обновляет статус, `finalized_at`, `error_summary` и данные `capturedCost`.
+- `SessionTransactionBound` сохраняет `trx_id`, RRN, approval code и данные конвертации.
+- `SessionProxyStateChanged` используется как fallback для `trx_id`.
+
+Если в одном `MachineEvent` есть несколько изменений одного платежа, они объединяются в порядке поступления.
+При нескольких status-event последнее изменение статуса определяет `status`, `finalized_at` и `error_summary`.
+
+## Withdrawals
+
+`WithdrawalEventProjector` обрабатывает:
+
+- `created`: исходные данные вывода, body, маршрут и quote;
+- `body_changed`: заменяет текущие `amount` и `currency` значениями из `new_body`;
+- `route`: обновляет provider/terminal;
+- `status_changed`: обновляет статус, `finalized_at` и `error_summary`;
+- `transfer.payload.created.transfer.cashflow`: обновляет `fee`.
+
+`original_amount`, `original_currency`, `provider_amount`, `provider_currency` и внутренний курс первоначально
+вычисляются из quote события создания. Текущая сумма вывода при этом может измениться отдельным `body_changed`.
+
+`WithdrawalSessionEventProjector` хранит связь с выводом и транзакционные данные сессии (`trx_id`, `trx_search`).
+В CSV используется последняя подходящая сессия.
+
+## Построение CSV
+
+Один отчёт выполняется в транзакции `READ ONLY REPEATABLE READ` и использует один согласованный снимок PostgreSQL.
+Данные читаются курсором, поэтому весь набор строк не загружается в память.
+
+Денежные значения хранятся в minor units и при записи CSV переводятся в decimal по exponent валюты.
+`exchange_rate_internal` записывается как обычное десятичное число без экспоненциальной формы.
+
+Локальный временный CSV удаляется при ошибке генерации. После успешной загрузки жизненный цикл файла контролируется
+через запись отчёта и TTL внешнего хранилища.
diff --git a/mockgit.txt b/mockgit.txt
new file mode 100644
index 0000000..d019ed9
--- /dev/null
+++ b/mockgit.txt
@@ -0,0 +1,25 @@
+```
+set -e
+
+git fetch origin
+
+# Пустой базовый commit ветки mock
+MOCK_COMMIT=$(git rev-parse origin/mock)
+
+# Актуальное дерево ветки init
+INIT_TREE=$(git rev-parse 'origin/init^{tree}')
+
+# Создаём новый review-commit:
+# содержимое = актуальный init
+# родитель = пустой mock
+REVIEW_COMMIT=$(
+ printf '%s\n' "Full init code snapshot for review" |
+ git commit-tree "$INIT_TREE" -p "$MOCK_COMMIT"
+)
+
+# Обновляем техническую ветку
+git branch -f init-full-review "$REVIEW_COMMIT"
+
+# Обновляем PR
+git push --force-with-lease origin init-full-review
+```
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..4cb3830
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,339 @@
+
+
+ 4.0.0
+
+
+ dev.vality
+ service-parent-pom
+ 4.0.1
+
+
+ cc-reporter
+ 1.0.0
+ jar
+
+ cc-reporter
+ CC Reporter
+
+
+ 25
+ 25
+ ${env.REGISTRY}
+ 8022
+ 8023
+ ${server.port} ${management.port}
+ jdbc:postgresql://localhost:5432/cc_reporter
+ postgres
+ postgres
+ cc_reporter
+ ccr
+ jdbc:postgresql://localhost:5432/cc_reporter
+ 5432
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-flyway
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+ org.springframework.boot
+ spring-boot-kafka
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ org.postgresql
+ postgresql
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
+
+ org.jooq
+ jooq
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+
+
+ io.opentelemetry
+ opentelemetry-api
+
+
+
+
+ dev.vality.woody
+ woody-thrift
+
+
+ dev.vality.woody
+ woody-api
+
+
+ dev.vality
+ cc-reporter-proto
+ 1.3-e7841ec
+
+
+ dev.vality
+ file-storage-proto
+ 1.49-f01d2d9
+
+
+ dev.vality
+ msgpack-proto
+
+
+ dev.vality
+ machinegun-proto
+ 1.43-3decc8f
+
+
+ dev.vality
+ damsel
+
+
+ dev.vality.geck
+ serializer
+
+
+ dev.vality
+ fistful-proto
+ 1.194-360c737
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ io.zonky.test
+ embedded-postgres
+ 2.0.3
+ test
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+
+
+
+
+ ${project.build.directory}/maven-shared-archive-resources
+ ${project.build.directory}
+
+ Dockerfile
+
+ true
+
+
+ ${project.build.directory}/maven-shared-archive-resources
+ true
+
+ Dockerfile
+ opentelemetry-javaagent.jar
+
+
+
+ src/main/resources
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ dev.vality.maven.plugins
+ pg-embedded-plugin
+ 3.0.0
+
+ ${local.pg.port}
+ ${db.name}
+
+ ${db.schema}
+
+
+
+
+ PG_server_start
+ generate-sources
+
+ start
+
+
+
+ PG_server_stop
+ process-sources
+
+ stop
+
+
+
+
+
+ org.flywaydb
+ flyway-maven-plugin
+
+ ${local.pg.url}
+ ${db.user}
+ ${db.password}
+
+ ${db.schema}
+
+
+
+
+ migrate
+ generate-sources
+
+ migrate
+
+
+
+
+
+ org.postgresql
+ postgresql
+ ${postgresql.version}
+
+
+
+
+ org.jooq
+ jooq-codegen-maven
+
+
+ org.postgresql.Driver
+ ${local.pg.url}
+ ${db.user}
+ ${db.password}
+
+
+
+ true
+ true
+ true
+ true
+ true
+
+
+ org.jooq.meta.postgres.PostgresDatabase
+ .*
+
+ schema_version|.*func|get_adjustment.*|get_cashflow.*|get_payment.*|get_payout.*|get_refund.*|.*_new|.*_20.*
+
+ ${db.schema}
+
+
+ dev.vality.ccreporter.domain
+ target/generated-sources/
+
+
+
+
+
+ gen-src
+ generate-sources
+
+ generate
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ -Dfile.encoding=UTF-8
+
+
+
+ org.apache.maven.plugins
+ maven-remote-resources-plugin
+ 3.1.0
+
+
+ org.apache.maven.shared
+ maven-filtering
+ 3.3.1
+
+
+
+
+ dev.vality:shared-resources:${shared-resources.version}
+
+ false
+ false
+
+
+
+
+ process
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+
+
+ p12
+
+
+
+
+
+
+
diff --git a/src/main/java/dev/vality/ccreporter/ServiceApplication.java b/src/main/java/dev/vality/ccreporter/ServiceApplication.java
new file mode 100644
index 0000000..0e6de75
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ServiceApplication.java
@@ -0,0 +1,15 @@
+package dev.vality.ccreporter;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+
+@SpringBootApplication
+@ConfigurationPropertiesScan
+public class ServiceApplication {
+
+ static void main(String[] args) {
+ SpringApplication.run(ServiceApplication.class, args);
+ }
+
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/FileStorageConfig.java b/src/main/java/dev/vality/ccreporter/config/FileStorageConfig.java
new file mode 100644
index 0000000..58fc513
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/FileStorageConfig.java
@@ -0,0 +1,30 @@
+package dev.vality.ccreporter.config;
+
+import dev.vality.ccreporter.config.properties.FileStorageProperties;
+import dev.vality.file.storage.FileStorageSrv;
+import dev.vality.woody.thrift.impl.http.THSpawnClientBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.time.Duration;
+
+@Configuration
+public class FileStorageConfig {
+
+ @Bean
+ public HttpClient httpClient(FileStorageProperties fileStorageProperties) {
+ return HttpClient.newBuilder()
+ .connectTimeout(Duration.ofMillis(fileStorageProperties.getNetworkTimeout()))
+ .build();
+ }
+
+ @Bean
+ public FileStorageSrv.Iface fileStorageClient(FileStorageProperties fileStorageProperties) {
+ return new THSpawnClientBuilder()
+ .withAddress(URI.create(fileStorageProperties.getUrl()))
+ .withNetworkTimeout(fileStorageProperties.getNetworkTimeout())
+ .build(FileStorageSrv.Iface.class);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/JacksonConfig.java b/src/main/java/dev/vality/ccreporter/config/JacksonConfig.java
new file mode 100644
index 0000000..25d8615
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/JacksonConfig.java
@@ -0,0 +1,20 @@
+package dev.vality.ccreporter.config;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class JacksonConfig {
+
+ @Bean
+ public ObjectMapper objectMapper() {
+ return new ObjectMapper()
+ .setDefaultPropertyInclusion(JsonInclude.Include.NON_EMPTY)
+ .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/JooqConfig.java b/src/main/java/dev/vality/ccreporter/config/JooqConfig.java
new file mode 100644
index 0000000..55fa056
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/JooqConfig.java
@@ -0,0 +1,29 @@
+package dev.vality.ccreporter.config;
+
+import org.jooq.DSLContext;
+import org.jooq.SQLDialect;
+import org.jooq.impl.DataSourceConnectionProvider;
+import org.jooq.impl.DefaultConfiguration;
+import org.jooq.impl.DefaultDSLContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy;
+
+import javax.sql.DataSource;
+
+@Configuration
+public class JooqConfig {
+
+ @Bean
+ public org.jooq.Configuration jooqConfiguration(DataSource dataSource) {
+ var configuration = new DefaultConfiguration();
+ configuration.set(SQLDialect.POSTGRES);
+ configuration.set(new DataSourceConnectionProvider(new TransactionAwareDataSourceProxy(dataSource)));
+ return configuration;
+ }
+
+ @Bean
+ public DSLContext dslContext(org.jooq.Configuration configuration) {
+ return new DefaultDSLContext(configuration);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/KafkaIngestionConfig.java b/src/main/java/dev/vality/ccreporter/config/KafkaIngestionConfig.java
new file mode 100644
index 0000000..7bd5c5d
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/KafkaIngestionConfig.java
@@ -0,0 +1,162 @@
+package dev.vality.ccreporter.config;
+
+import dev.vality.ccreporter.config.properties.CcrKafkaProperties;
+import dev.vality.ccreporter.serde.thrift.MachineEventParser;
+import dev.vality.ccreporter.serde.thrift.ThriftDeserializer;
+import dev.vality.damsel.domain_config_v2.HistoricalCommit;
+import dev.vality.damsel.payment_processing.EventPayload;
+import dev.vality.machinegun.eventsink.SinkEvent;
+import lombok.RequiredArgsConstructor;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.kafka.autoconfigure.KafkaProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.listener.DefaultErrorHandler;
+import org.springframework.util.backoff.FixedBackOff;
+
+@Configuration
+@EnableKafka
+@RequiredArgsConstructor
+@EnableConfigurationProperties(CcrKafkaProperties.class)
+public class KafkaIngestionConfig {
+
+ private final KafkaProperties kafkaProperties;
+ private final CcrKafkaProperties ccrKafkaProperties;
+
+ @Bean
+ public MachineEventParser paymentEventPayloadMachineEventParser() {
+ return new MachineEventParser<>(new ThriftDeserializer<>(EventPayload::new));
+ }
+
+ @Bean
+ public MachineEventParser withdrawalEventMachineEventParser() {
+ return new MachineEventParser<>(new ThriftDeserializer<>(dev.vality.fistful.withdrawal.TimestampedChange::new));
+ }
+
+ @Bean
+ public MachineEventParser
+ withdrawalSessionEventMachineEventParser() {
+ return new MachineEventParser<>(
+ new ThriftDeserializer<>(dev.vality.fistful.withdrawal_session.TimestampedChange::new)
+ );
+ }
+
+ @Bean
+ public ConsumerFactory sinkEventConsumerFactory() {
+ return new DefaultKafkaConsumerFactory<>(
+ kafkaProperties.buildConsumerProperties(),
+ new StringDeserializer(),
+ new ThriftDeserializer<>(SinkEvent::new)
+ );
+ }
+
+ @Bean
+ public ConsumerFactory dominantConsumerFactory() {
+ return new DefaultKafkaConsumerFactory<>(
+ kafkaProperties.buildConsumerProperties(),
+ new StringDeserializer(),
+ new ThriftDeserializer<>(HistoricalCommit::new)
+ );
+ }
+
+ @Bean
+ public DefaultErrorHandler kafkaErrorHandler() {
+ var consumer = ccrKafkaProperties.getConsumer();
+ return createErrorHandler(consumer.getErrorBackoffIntervalMs(), consumer.getErrorMaxAttempts());
+ }
+
+ @Bean
+ public DefaultErrorHandler dominantKafkaErrorHandler() {
+ var consumer = ccrKafkaProperties.getConsumer();
+ return createErrorHandler(
+ consumer.getDominantErrorBackoffIntervalMs(),
+ consumer.getDominantErrorMaxAttempts()
+ );
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory paymentsKafkaListenerContainerFactory(
+ ConsumerFactory sinkEventConsumerFactory,
+ @Qualifier("kafkaErrorHandler") DefaultErrorHandler kafkaErrorHandler
+ ) {
+ return listenerContainerFactory(
+ sinkEventConsumerFactory,
+ ccrKafkaProperties.getConsumer().getPaymentsConcurrency(),
+ kafkaErrorHandler
+ );
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory withdrawalsKafkaListenerContainerFactory(
+ ConsumerFactory sinkEventConsumerFactory,
+ @Qualifier("kafkaErrorHandler") DefaultErrorHandler kafkaErrorHandler
+ ) {
+ return listenerContainerFactory(
+ sinkEventConsumerFactory,
+ ccrKafkaProperties.getConsumer().getWithdrawalsConcurrency(),
+ kafkaErrorHandler
+ );
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory dominantKafkaListenerContainerFactory(
+ ConsumerFactory dominantConsumerFactory,
+ @Qualifier("dominantKafkaErrorHandler") DefaultErrorHandler dominantKafkaErrorHandler
+ ) {
+ return listenerContainerFactory(
+ dominantConsumerFactory,
+ ccrKafkaProperties.getConsumer().getDominantConcurrency(),
+ dominantKafkaErrorHandler
+ );
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory withdrawalSessionsKafkaListenerContainerFactory(
+ ConsumerFactory sinkEventConsumerFactory,
+ @Qualifier("kafkaErrorHandler") DefaultErrorHandler kafkaErrorHandler
+ ) {
+ return listenerContainerFactory(
+ sinkEventConsumerFactory,
+ ccrKafkaProperties.getConsumer().getWithdrawalSessionsConcurrency(),
+ kafkaErrorHandler
+ );
+ }
+
+ private ConcurrentKafkaListenerContainerFactory listenerContainerFactory(
+ ConsumerFactory consumerFactory,
+ int concurrency,
+ DefaultErrorHandler errorHandler
+ ) {
+ var factory = new ConcurrentKafkaListenerContainerFactory();
+ factory.setConsumerFactory(consumerFactory);
+ factory.setBatchListener(true);
+ factory.setConcurrency(concurrency);
+ factory.setCommonErrorHandler(errorHandler);
+ configureListener(factory);
+ return factory;
+ }
+
+ private void configureListener(ConcurrentKafkaListenerContainerFactory, ?> factory) {
+ var listener = kafkaProperties.getListener();
+ if (listener.getAckMode() != null) {
+ factory.getContainerProperties().setAckMode(listener.getAckMode());
+ }
+ if (listener.getPollTimeout() != null) {
+ factory.getContainerProperties().setPollTimeout(listener.getPollTimeout().toMillis());
+ }
+ }
+
+ private DefaultErrorHandler createErrorHandler(long interval, long maxAttempts) {
+ return new DefaultErrorHandler(new FixedBackOff(interval, resolveMaxAttempts(maxAttempts)));
+ }
+
+ private long resolveMaxAttempts(long maxAttempts) {
+ return maxAttempts < 0 ? FixedBackOff.UNLIMITED_ATTEMPTS : maxAttempts;
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/ReportWorkerConfig.java b/src/main/java/dev/vality/ccreporter/config/ReportWorkerConfig.java
new file mode 100644
index 0000000..7da729a
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/ReportWorkerConfig.java
@@ -0,0 +1,36 @@
+package dev.vality.ccreporter.config;
+
+import com.zaxxer.hikari.HikariDataSource;
+import dev.vality.ccreporter.config.properties.ReportProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.sql.DataSource;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+@Configuration
+public class ReportWorkerConfig {
+
+ private static final int MINIMUM_CONNECTION_RESERVE = 2;
+
+ @Bean(destroyMethod = "shutdownNow")
+ public ExecutorService reportWorkerExecutor(ReportProperties reportProperties, DataSource dataSource) {
+ validatePoolCapacity(reportProperties, dataSource);
+ var threadFactory = Thread.ofPlatform()
+ .name("ccr-report-worker-", 0)
+ .factory();
+ return Executors.newFixedThreadPool(reportProperties.getWorkerConcurrency(), threadFactory);
+ }
+
+ private void validatePoolCapacity(ReportProperties reportProperties, DataSource dataSource) {
+ var minimumPoolSize = reportProperties.getWorkerConcurrency() + MINIMUM_CONNECTION_RESERVE;
+ if (dataSource instanceof HikariDataSource hikariDataSource
+ && hikariDataSource.getMaximumPoolSize() < minimumPoolSize) {
+ throw new IllegalStateException(
+ "spring.datasource.hikari.maximum-pool-size must be at least " + minimumPoolSize +
+ " for report.worker-concurrency=" + reportProperties.getWorkerConcurrency()
+ );
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/SchedulingConfig.java b/src/main/java/dev/vality/ccreporter/config/SchedulingConfig.java
new file mode 100644
index 0000000..6c4d370
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/SchedulingConfig.java
@@ -0,0 +1,9 @@
+package dev.vality.ccreporter.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@Configuration
+@EnableScheduling
+public class SchedulingConfig {
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/ThriftEndpointConfig.java b/src/main/java/dev/vality/ccreporter/config/ThriftEndpointConfig.java
new file mode 100644
index 0000000..f5c4652
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/ThriftEndpointConfig.java
@@ -0,0 +1,51 @@
+package dev.vality.ccreporter.config;
+
+import dev.vality.ccreporter.ReportingSrv;
+import dev.vality.ccreporter.config.properties.CcrApiProperties;
+import dev.vality.woody.thrift.impl.http.THServiceBuilder;
+import jakarta.servlet.*;
+import org.springframework.boot.web.servlet.ServletRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.io.IOException;
+
+@Configuration
+public class ThriftEndpointConfig {
+
+ @Bean
+ public ServletRegistrationBean reportingServlet(
+ ReportingSrv.Iface requestHandler,
+ CcrApiProperties apiProperties
+ ) {
+ var registrationBean =
+ new ServletRegistrationBean(
+ new ReportingServlet(requestHandler),
+ apiProperties.getPath()
+ );
+ registrationBean.setName("ccrReportingThriftServlet");
+ registrationBean.setLoadOnStartup(1);
+ return registrationBean;
+ }
+
+ private static final class ReportingServlet extends GenericServlet {
+
+ private final ReportingSrv.Iface requestHandler;
+ private Servlet thriftServlet;
+
+ private ReportingServlet(ReportingSrv.Iface requestHandler) {
+ this.requestHandler = requestHandler;
+ }
+
+ @Override
+ public void init(ServletConfig config) throws ServletException {
+ super.init(config);
+ thriftServlet = new THServiceBuilder().build(ReportingSrv.Iface.class, requestHandler);
+ }
+
+ @Override
+ public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
+ thriftServlet.service(req, res);
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/properties/CcrApiProperties.java b/src/main/java/dev/vality/ccreporter/config/properties/CcrApiProperties.java
new file mode 100644
index 0000000..8b9d0e0
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/properties/CcrApiProperties.java
@@ -0,0 +1,16 @@
+package dev.vality.ccreporter.config.properties;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "api")
+public class CcrApiProperties {
+
+ private String path;
+ private int defaultPageSize;
+ private int maxPageSize;
+
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/properties/CcrKafkaProperties.java b/src/main/java/dev/vality/ccreporter/config/properties/CcrKafkaProperties.java
new file mode 100644
index 0000000..cad4889
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/properties/CcrKafkaProperties.java
@@ -0,0 +1,38 @@
+package dev.vality.ccreporter.config.properties;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "kafka")
+public class CcrKafkaProperties {
+
+ private Consumer consumer;
+ private Topics topics;
+
+ @Data
+ public static class Consumer {
+ private int dominantConcurrency;
+ private int paymentsConcurrency;
+ private int withdrawalsConcurrency;
+ private int withdrawalSessionsConcurrency;
+ private long errorBackoffIntervalMs;
+ private long errorMaxAttempts;
+ private long dominantErrorBackoffIntervalMs;
+ private long dominantErrorMaxAttempts;
+ }
+
+ @Data
+ public static class Topics {
+ private Topic dominant;
+ private Topic payments;
+ private Topic withdrawals;
+ private Topic withdrawalSessions;
+ }
+
+ @Data
+ public static class Topic {
+ private String id;
+ private boolean enabled;
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/properties/FileStorageProperties.java b/src/main/java/dev/vality/ccreporter/config/properties/FileStorageProperties.java
new file mode 100644
index 0000000..6343427
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/properties/FileStorageProperties.java
@@ -0,0 +1,15 @@
+package dev.vality.ccreporter.config.properties;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "storage.file-storage")
+public class FileStorageProperties {
+
+ private String url;
+ private int networkTimeout;
+
+}
diff --git a/src/main/java/dev/vality/ccreporter/config/properties/ReportProperties.java b/src/main/java/dev/vality/ccreporter/config/properties/ReportProperties.java
new file mode 100644
index 0000000..b582d80
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/config/properties/ReportProperties.java
@@ -0,0 +1,23 @@
+package dev.vality.ccreporter.config.properties;
+
+import jakarta.validation.constraints.Positive;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.validation.annotation.Validated;
+
+@Data
+@Validated
+@Configuration
+@ConfigurationProperties(prefix = "report")
+public class ReportProperties {
+
+ private int maxAttempts;
+ @Positive
+ private int workerConcurrency;
+ @Positive
+ private long processingTimeoutMs;
+ private int presignedUrlTtlSec;
+ private long expirationSec;
+
+}
diff --git a/src/main/java/dev/vality/ccreporter/constants/ReportAuditEventType.java b/src/main/java/dev/vality/ccreporter/constants/ReportAuditEventType.java
new file mode 100644
index 0000000..a8ac1aa
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/constants/ReportAuditEventType.java
@@ -0,0 +1,16 @@
+package dev.vality.ccreporter.constants;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum ReportAuditEventType {
+
+ REPORT_CREATED("report_created"),
+ REPORT_CANCELED("report_canceled"),
+ PRESIGNED_URL_GENERATED("presigned_url_generated");
+
+ private final String eventType;
+
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/DominantLookupDao.java b/src/main/java/dev/vality/ccreporter/dao/DominantLookupDao.java
new file mode 100644
index 0000000..31a571c
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/DominantLookupDao.java
@@ -0,0 +1,140 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.domain.tables.pojos.ProviderLookup;
+import dev.vality.ccreporter.domain.tables.pojos.ShopLookup;
+import dev.vality.ccreporter.domain.tables.pojos.TerminalLookup;
+import dev.vality.ccreporter.domain.tables.pojos.WalletLookup;
+import lombok.RequiredArgsConstructor;
+import org.jooq.*;
+import org.jooq.Record;
+import org.jooq.impl.DSL;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+
+import static dev.vality.ccreporter.dao.support.DaoUpsertUtils.buildLookupUpsertMap;
+import static dev.vality.ccreporter.domain.Tables.*;
+import static dev.vality.ccreporter.util.SearchValueNormalizer.normalize;
+
+@Repository
+@RequiredArgsConstructor
+public class DominantLookupDao {
+
+ private final DSLContext dslContext;
+
+ public void upsert(LookupType lookupType, String id, String name, long dominantVersionId, boolean deleted) {
+ switch (lookupType) {
+ case SHOP -> insertShop(id, name, dominantVersionId, deleted);
+ case PROVIDER -> insertProvider(id, name, dominantVersionId, deleted);
+ case TERMINAL -> insertTerminal(id, name, dominantVersionId, deleted);
+ case WALLET -> insertWallet(id, name, dominantVersionId, deleted);
+ default -> throw new IllegalArgumentException();
+ }
+ }
+
+ private void insertShop(String shopId, String shopName, long dominantVersionId, boolean deleted) {
+ if (isBlank(shopId)) {
+ return;
+ }
+ upsertEntity(
+ SHOP_LOOKUP,
+ SHOP_LOOKUP.SHOP_ID,
+ SHOP_LOOKUP.DOMINANT_VERSION_ID,
+ SHOP_LOOKUP.UPDATED_AT,
+ new ShopLookup()
+ .setShopId(shopId)
+ .setShopName(shopName)
+ .setShopSearch(searchValue(shopId, shopName))
+ .setDominantVersionId(dominantVersionId)
+ .setDeleted(deleted)
+ );
+ }
+
+ private void insertProvider(String providerId, String providerName, long dominantVersionId, boolean deleted) {
+ if (isBlank(providerId)) {
+ return;
+ }
+ upsertEntity(
+ PROVIDER_LOOKUP,
+ PROVIDER_LOOKUP.PROVIDER_ID,
+ PROVIDER_LOOKUP.DOMINANT_VERSION_ID,
+ PROVIDER_LOOKUP.UPDATED_AT,
+ new ProviderLookup()
+ .setProviderId(providerId)
+ .setProviderName(providerName)
+ .setProviderSearch(searchValue(providerId, providerName))
+ .setDominantVersionId(dominantVersionId)
+ .setDeleted(deleted)
+ );
+ }
+
+ private void insertTerminal(String terminalId, String terminalName, long dominantVersionId, boolean deleted) {
+ if (isBlank(terminalId)) {
+ return;
+ }
+ upsertEntity(
+ TERMINAL_LOOKUP,
+ TERMINAL_LOOKUP.TERMINAL_ID,
+ TERMINAL_LOOKUP.DOMINANT_VERSION_ID,
+ TERMINAL_LOOKUP.UPDATED_AT,
+ new TerminalLookup()
+ .setTerminalId(terminalId)
+ .setTerminalName(terminalName)
+ .setTerminalSearch(searchValue(terminalId, terminalName))
+ .setDominantVersionId(dominantVersionId)
+ .setDeleted(deleted)
+ );
+ }
+
+ private void insertWallet(String walletId, String walletName, long dominantVersionId, boolean deleted) {
+ if (isBlank(walletId)) {
+ return;
+ }
+ upsertEntity(
+ WALLET_LOOKUP,
+ WALLET_LOOKUP.WALLET_ID,
+ WALLET_LOOKUP.DOMINANT_VERSION_ID,
+ WALLET_LOOKUP.UPDATED_AT,
+ new WalletLookup()
+ .setWalletId(walletId)
+ .setWalletName(walletName)
+ .setWalletSearch(searchValue(walletId, walletName))
+ .setDominantVersionId(dominantVersionId)
+ .setDeleted(deleted)
+ );
+ }
+
+ private void upsertEntity(
+ Table table,
+ TableField idField,
+ TableField versionField,
+ Field updatedAtField,
+ P update
+ ) {
+ var record = dslContext.newRecord(table, update);
+ record.changed(updatedAtField, false);
+
+ dslContext.insertInto(table)
+ .set(record)
+ .onConflict(idField)
+ .doUpdate()
+ .set(buildLookupUpsertMap(table, idField, updatedAtField))
+ .where(versionField.le(DSL.excluded(versionField)))
+ .execute();
+ }
+
+ private boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+
+ private String searchValue(String id, String name) {
+ return isBlank(name) ? normalize(id) : normalize(id, name);
+ }
+
+ public enum LookupType {
+ SHOP,
+ PROVIDER,
+ TERMINAL,
+ WALLET
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/PaymentTxnCurrentDao.java b/src/main/java/dev/vality/ccreporter/dao/PaymentTxnCurrentDao.java
new file mode 100644
index 0000000..b066604
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/PaymentTxnCurrentDao.java
@@ -0,0 +1,64 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.domain.tables.pojos.PaymentTxnCurrent;
+import lombok.RequiredArgsConstructor;
+import org.jooq.DSLContext;
+import org.jooq.Field;
+import org.jooq.impl.DSL;
+import org.springframework.stereotype.Repository;
+
+import java.util.Map;
+import java.util.Set;
+
+import static dev.vality.ccreporter.dao.support.DaoUpsertUtils.*;
+import static dev.vality.ccreporter.domain.Tables.PAYMENT_TXN_CURRENT;
+
+@Repository
+@RequiredArgsConstructor
+public class PaymentTxnCurrentDao {
+
+ private static final Set> IMMUTABLE_FIELDS = Set.of(
+ PAYMENT_TXN_CURRENT.ID,
+ PAYMENT_TXN_CURRENT.INVOICE_ID,
+ PAYMENT_TXN_CURRENT.PAYMENT_ID
+ );
+
+ private static final Set> OVERWRITE_FIELDS = Set.of(
+ PAYMENT_TXN_CURRENT.DOMAIN_EVENT_ID,
+ PAYMENT_TXN_CURRENT.DOMAIN_EVENT_CREATED_AT
+ );
+
+ private final DSLContext dslContext;
+
+ public void upsert(PaymentTxnCurrent update) {
+ var record = dslContext.newRecord(PAYMENT_TXN_CURRENT, update);
+ record.changed(PAYMENT_TXN_CURRENT.ID, false);
+ record.changed(PAYMENT_TXN_CURRENT.UPDATED_AT, false);
+
+ dslContext.insertInto(PAYMENT_TXN_CURRENT)
+ .set(record)
+ .onConflict(PAYMENT_TXN_CURRENT.INVOICE_ID, PAYMENT_TXN_CURRENT.PAYMENT_ID)
+ .doUpdate()
+ .set(buildUpsertMap(
+ PAYMENT_TXN_CURRENT,
+ IMMUTABLE_FIELDS,
+ OVERWRITE_FIELDS,
+ Map.of(
+ PAYMENT_TXN_CURRENT.UPDATED_AT,
+ UTC_NOW,
+ PAYMENT_TXN_CURRENT.FINALIZED_AT,
+ DSL.when(
+ DSL.excluded(PAYMENT_TXN_CURRENT.STATUS).isNotNull(),
+ DSL.excluded(PAYMENT_TXN_CURRENT.FINALIZED_AT)
+ ).otherwise(PAYMENT_TXN_CURRENT.FINALIZED_AT),
+ PAYMENT_TXN_CURRENT.ERROR_SUMMARY,
+ DSL.when(
+ DSL.excluded(PAYMENT_TXN_CURRENT.STATUS).isNotNull(),
+ DSL.excluded(PAYMENT_TXN_CURRENT.ERROR_SUMMARY)
+ ).otherwise(PAYMENT_TXN_CURRENT.ERROR_SUMMARY)
+ )
+ ))
+ .where(isIncomingEventNewer(PAYMENT_TXN_CURRENT.DOMAIN_EVENT_ID))
+ .execute();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/ReportAuditDao.java b/src/main/java/dev/vality/ccreporter/dao/ReportAuditDao.java
new file mode 100644
index 0000000..feddbbe
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/ReportAuditDao.java
@@ -0,0 +1,36 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.dao.mapper.ReportAuditMapper;
+import dev.vality.ccreporter.model.RequestAuditMetadata;
+import lombok.RequiredArgsConstructor;
+import org.jooq.DSLContext;
+import org.springframework.stereotype.Repository;
+
+import static dev.vality.ccreporter.domain.Tables.REPORT_AUDIT_EVENT;
+
+@Repository
+@RequiredArgsConstructor
+public class ReportAuditDao {
+
+ private final DSLContext dslContext;
+ private final ReportAuditMapper reportAuditMapper;
+
+ public void insertEvent(
+ long reportId,
+ String eventType,
+ String actor,
+ RequestAuditMetadata metadata,
+ Object details
+ ) {
+ dslContext.insertInto(REPORT_AUDIT_EVENT)
+ .set(reportAuditMapper.newInsertableRecord(
+ dslContext,
+ reportId,
+ eventType,
+ actor,
+ metadata,
+ details
+ ))
+ .execute();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/ReportCommandDao.java b/src/main/java/dev/vality/ccreporter/dao/ReportCommandDao.java
new file mode 100644
index 0000000..eca87e0
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/ReportCommandDao.java
@@ -0,0 +1,91 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.FileType;
+import dev.vality.ccreporter.ReportQuery;
+import dev.vality.ccreporter.ReportType;
+import dev.vality.ccreporter.dao.mapper.ReportRecordMapper;
+import dev.vality.ccreporter.serde.json.ThriftJsonCodec;
+import lombok.RequiredArgsConstructor;
+import org.jooq.DSLContext;
+import org.jooq.JSONB;
+import org.springframework.stereotype.Repository;
+import org.springframework.util.StringUtils;
+
+import java.util.Optional;
+
+import static dev.vality.ccreporter.domain.Tables.REPORT_JOB;
+
+@Repository
+@RequiredArgsConstructor
+public class ReportCommandDao {
+
+ private final DSLContext dslContext;
+ private final ThriftJsonCodec thriftJsonCodec;
+
+ public CreateResult createReport(
+ String createdBy,
+ ReportType reportType,
+ FileType fileType,
+ ReportQuery query,
+ String timezone,
+ String idempotencyKey
+ ) {
+ var normalizedIdempotencyKey = StringUtils.hasText(idempotencyKey) ? idempotencyKey : null;
+ var insertedId = dslContext.insertInto(REPORT_JOB)
+ .columns(
+ REPORT_JOB.REPORT_TYPE,
+ REPORT_JOB.FILE_TYPE,
+ REPORT_JOB.QUERY_JSON,
+ REPORT_JOB.TIMEZONE,
+ REPORT_JOB.CREATED_BY,
+ REPORT_JOB.IDEMPOTENCY_KEY
+ )
+ .values(
+ ReportRecordMapper.mapEnum(
+ reportType,
+ dev.vality.ccreporter.domain.enums.ReportType.class
+ ),
+ ReportRecordMapper.mapEnum(
+ fileType,
+ dev.vality.ccreporter.domain.enums.FileType.class
+ ),
+ JSONB.jsonb(thriftJsonCodec.serialize(query)),
+ timezone,
+ createdBy,
+ normalizedIdempotencyKey
+ )
+ .onConflictDoNothing()
+ .returningResult(REPORT_JOB.ID)
+ .fetchOptional(REPORT_JOB.ID);
+
+ if (insertedId.isPresent()) {
+ return new CreateResult(insertedId.get(), true);
+ }
+ if (normalizedIdempotencyKey == null) {
+ throw new IllegalStateException("Report insert returned no id");
+ }
+ return findByIdempotencyKey(createdBy, normalizedIdempotencyKey)
+ .map(reportId -> new CreateResult(reportId, false))
+ .orElseThrow(() -> new IllegalStateException("Conflicting report was not found"));
+ }
+
+ public boolean reportExists(String createdBy, long reportId) {
+ return dslContext.fetchExists(
+ dslContext.selectOne()
+ .from(REPORT_JOB)
+ .where(REPORT_JOB.ID.eq(reportId))
+ .and(REPORT_JOB.CREATED_BY.eq(createdBy))
+ );
+ }
+
+ private Optional findByIdempotencyKey(String createdBy, String idempotencyKey) {
+ return dslContext.select(REPORT_JOB.ID)
+ .from(REPORT_JOB)
+ .where(REPORT_JOB.CREATED_BY.eq(createdBy))
+ .and(REPORT_JOB.IDEMPOTENCY_KEY.eq(idempotencyKey))
+ .fetchOptional(REPORT_JOB.ID);
+ }
+
+ public record CreateResult(long reportId, boolean created) {
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/ReportCsvDao.java b/src/main/java/dev/vality/ccreporter/dao/ReportCsvDao.java
new file mode 100644
index 0000000..c29b53b
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/ReportCsvDao.java
@@ -0,0 +1,232 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.PaymentsQuery;
+import dev.vality.ccreporter.WithdrawalsQuery;
+import lombok.RequiredArgsConstructor;
+import org.jooq.*;
+import org.jooq.Record;
+import org.jooq.impl.DSL;
+import org.springframework.stereotype.Repository;
+
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import static dev.vality.ccreporter.domain.Tables.*;
+import static dev.vality.ccreporter.util.SearchValueNormalizer.normalize;
+import static dev.vality.ccreporter.util.TimestampUtils.parse;
+import static dev.vality.ccreporter.util.TimestampUtils.toLocalDateTime;
+
+@Repository
+@RequiredArgsConstructor
+public class ReportCsvDao {
+
+ private static final int CSV_QUERY_FETCH_SIZE = 1_000;
+ private static final String CREATED_AT = "created_at";
+ private static final String FINALIZED_AT = "finalized_at";
+ private static final String PROVIDER_CURRENCY = "provider_currency";
+ private static final String ORIGINAL_CURRENCY = "original_currency";
+ private static final String CURRENCY = "currency";
+
+ private final DSLContext dslContext;
+
+ public Instant currentSnapshot() {
+ return dslContext.select(DSL.currentOffsetDateTime())
+ .fetchSingle(0, OffsetDateTime.class)
+ .toInstant();
+ }
+
+ public void setLocalStatementTimeout(long timeoutMs) {
+ dslContext.fetchSingle(
+ "SELECT set_config('statement_timeout', ?, true)",
+ timeoutMs + "ms"
+ );
+ }
+
+ public Cursor extends Record> fetchPayments(PaymentsQuery query) {
+ var conditions = buildPaymentsConditions(query);
+ return dslContext.select(
+ PAYMENT_TXN_CURRENT.CREATED_AT.as(CREATED_AT),
+ PAYMENT_TXN_CURRENT.FINALIZED_AT.as(FINALIZED_AT),
+ PAYMENT_TXN_CURRENT.INVOICE_ID.as("invoice_id"),
+ PAYMENT_TXN_CURRENT.PAYMENT_ID.as("payment_id"),
+ PAYMENT_TXN_CURRENT.STATUS.as("status"),
+ PAYMENT_TXN_CURRENT.AMOUNT.as("amount"),
+ PAYMENT_TXN_CURRENT.CURRENCY.as(CURRENCY),
+ PAYMENT_TXN_CURRENT.TRX_ID.as("trx_id"),
+ PAYMENT_TXN_CURRENT.PROVIDER_ID.as("provider_id"),
+ PAYMENT_TXN_CURRENT.TERMINAL_ID.as("terminal_id"),
+ PAYMENT_TXN_CURRENT.SHOP_ID.as("shop_id"),
+ PAYMENT_TXN_CURRENT.EXCHANGE_RATE_INTERNAL.as("exchange_rate_internal"),
+ PAYMENT_TXN_CURRENT.PROVIDER_AMOUNT.as("provider_amount"),
+ PAYMENT_TXN_CURRENT.PROVIDER_CURRENCY.as(PROVIDER_CURRENCY),
+ PAYMENT_TXN_CURRENT.ORIGINAL_AMOUNT.as("original_amount"),
+ PAYMENT_TXN_CURRENT.ORIGINAL_CURRENCY.as(ORIGINAL_CURRENCY),
+ PAYMENT_TXN_CURRENT.CONVERTED_AMOUNT.as("converted_amount")
+ )
+ .from(PAYMENT_TXN_CURRENT)
+ .leftJoin(SHOP_LOOKUP).on(SHOP_LOOKUP.SHOP_ID.eq(PAYMENT_TXN_CURRENT.SHOP_ID))
+ .leftJoin(PROVIDER_LOOKUP).on(PROVIDER_LOOKUP.PROVIDER_ID.eq(PAYMENT_TXN_CURRENT.PROVIDER_ID))
+ .leftJoin(TERMINAL_LOOKUP).on(TERMINAL_LOOKUP.TERMINAL_ID.eq(PAYMENT_TXN_CURRENT.TERMINAL_ID))
+ .where(conditions)
+ .orderBy(PAYMENT_TXN_CURRENT.CREATED_AT.asc(),
+ PAYMENT_TXN_CURRENT.INVOICE_ID.asc(),
+ PAYMENT_TXN_CURRENT.PAYMENT_ID.asc())
+ .fetchSize(CSV_QUERY_FETCH_SIZE)
+ .fetchLazy();
+ }
+
+ public Cursor extends Record> fetchWithdrawals(WithdrawalsQuery query) {
+ var latestSessionQuery = DSL.select(
+ WITHDRAWAL_SESSION.SESSION_ID.as("session_id"),
+ WITHDRAWAL_SESSION.TRX_ID.as("trx_id"),
+ WITHDRAWAL_SESSION.TRX_SEARCH.as("trx_search")
+ )
+ .from(WITHDRAWAL_SESSION)
+ .where(WITHDRAWAL_SESSION.WITHDRAWAL_ID.eq(WITHDRAWAL_TXN_CURRENT.WITHDRAWAL_ID))
+ .orderBy(
+ WITHDRAWAL_SESSION.DOMAIN_EVENT_CREATED_AT.desc(),
+ WITHDRAWAL_SESSION.DOMAIN_EVENT_ID.desc(),
+ WITHDRAWAL_SESSION.SESSION_ID.desc()
+ )
+ .limit(1);
+ var latestSession = DSL.lateral(latestSessionQuery).as("ws");
+ var latestSessionTrxId = latestSession.field("trx_id", String.class);
+ var latestSessionTrxSearch = latestSession.field("trx_search", String.class);
+ var latestSessionSessionId = latestSession.field("session_id", String.class);
+ var conditions = buildWithdrawalConditions(query, latestSessionTrxId, latestSessionTrxSearch);
+ return dslContext.select(
+ WITHDRAWAL_TXN_CURRENT.CREATED_AT.as(CREATED_AT),
+ WITHDRAWAL_TXN_CURRENT.FINALIZED_AT.as(FINALIZED_AT),
+ WITHDRAWAL_TXN_CURRENT.WITHDRAWAL_ID.as("withdrawal_id"),
+ WITHDRAWAL_TXN_CURRENT.STATUS.as("status"),
+ WITHDRAWAL_TXN_CURRENT.AMOUNT.as("amount"),
+ WITHDRAWAL_TXN_CURRENT.CURRENCY.as(CURRENCY),
+ latestSessionTrxId.as("trx_id"),
+ WITHDRAWAL_TXN_CURRENT.PROVIDER_ID.as("provider_id"),
+ WITHDRAWAL_TXN_CURRENT.TERMINAL_ID.as("terminal_id"),
+ WITHDRAWAL_TXN_CURRENT.WALLET_ID.as("wallet_id"),
+ WITHDRAWAL_TXN_CURRENT.EXCHANGE_RATE_INTERNAL.as("exchange_rate_internal"),
+ WITHDRAWAL_TXN_CURRENT.PROVIDER_AMOUNT.as("provider_amount"),
+ WITHDRAWAL_TXN_CURRENT.PROVIDER_CURRENCY.as(PROVIDER_CURRENCY),
+ WITHDRAWAL_TXN_CURRENT.ORIGINAL_AMOUNT.as("original_amount"),
+ WITHDRAWAL_TXN_CURRENT.ORIGINAL_CURRENCY.as(ORIGINAL_CURRENCY),
+ WITHDRAWAL_TXN_CURRENT.CONVERTED_AMOUNT.as("converted_amount")
+ )
+ .from(WITHDRAWAL_TXN_CURRENT)
+ .leftJoin(latestSession).on(DSL.trueCondition())
+ .leftJoin(WALLET_LOOKUP).on(WALLET_LOOKUP.WALLET_ID.eq(WITHDRAWAL_TXN_CURRENT.WALLET_ID))
+ .leftJoin(PROVIDER_LOOKUP).on(PROVIDER_LOOKUP.PROVIDER_ID.eq(WITHDRAWAL_TXN_CURRENT.PROVIDER_ID))
+ .leftJoin(TERMINAL_LOOKUP).on(TERMINAL_LOOKUP.TERMINAL_ID.eq(WITHDRAWAL_TXN_CURRENT.TERMINAL_ID))
+ .where(conditions)
+ .orderBy(
+ WITHDRAWAL_TXN_CURRENT.CREATED_AT.asc(),
+ WITHDRAWAL_TXN_CURRENT.WITHDRAWAL_ID.asc(),
+ latestSessionSessionId.asc()
+ )
+ .fetchSize(CSV_QUERY_FETCH_SIZE)
+ .fetchLazy();
+ }
+
+ private List buildPaymentsConditions(PaymentsQuery query) {
+ var conditions = new ArrayList();
+ conditions.add(PAYMENT_TXN_CURRENT.CREATED_AT.ge(toLocalDateTime(parse(query.getTimeRange().getFromTime()))));
+ conditions.add(PAYMENT_TXN_CURRENT.CREATED_AT.lt(toLocalDateTime(parse(query.getTimeRange().getToTime()))));
+ appendInCondition(conditions, PAYMENT_TXN_CURRENT.PARTY_ID, query.getPartyIds());
+ appendInCondition(conditions, PAYMENT_TXN_CURRENT.SHOP_ID, query.getShopIds());
+ appendInCondition(conditions, PAYMENT_TXN_CURRENT.PROVIDER_ID, query.getProviderIds());
+ appendInCondition(conditions, PAYMENT_TXN_CURRENT.TERMINAL_ID, query.getTerminalIds());
+ appendInCondition(conditions, PAYMENT_TXN_CURRENT.TRX_ID, query.getTrxIds());
+ appendInCondition(conditions, PAYMENT_TXN_CURRENT.CURRENCY, query.getCurrencies());
+ appendInCondition(conditions, PAYMENT_TXN_CURRENT.STATUS, query.getStatuses());
+ var filter = query.getFilter();
+ appendSearchCondition(
+ conditions,
+ SHOP_LOOKUP.SHOP_SEARCH,
+ filter == null ? null : filter.getShopTerm()
+ );
+ appendSearchCondition(
+ conditions,
+ PROVIDER_LOOKUP.PROVIDER_SEARCH,
+ filter == null ? null : filter.getProviderTerm()
+ );
+ appendSearchCondition(
+ conditions,
+ TERMINAL_LOOKUP.TERMINAL_SEARCH,
+ filter == null ? null : filter.getTerminalTerm()
+ );
+ appendSearchCondition(
+ conditions,
+ PAYMENT_TXN_CURRENT.TRX_SEARCH,
+ filter == null ? null : filter.getTrxTerm()
+ );
+ return conditions;
+ }
+
+ private List buildWithdrawalConditions(
+ WithdrawalsQuery query,
+ Field latestSessionTrxId,
+ Field latestSessionTrxSearch
+ ) {
+ var conditions = new ArrayList();
+ conditions.add(
+ WITHDRAWAL_TXN_CURRENT.CREATED_AT.ge(toLocalDateTime(parse(query.getTimeRange().getFromTime())))
+ );
+ conditions.add(
+ WITHDRAWAL_TXN_CURRENT.CREATED_AT.lt(toLocalDateTime(parse(query.getTimeRange().getToTime())))
+ );
+ appendInCondition(conditions, WITHDRAWAL_TXN_CURRENT.PARTY_ID, query.getPartyIds());
+ appendInCondition(conditions, WITHDRAWAL_TXN_CURRENT.WALLET_ID, query.getWalletIds());
+ appendInCondition(conditions, WITHDRAWAL_TXN_CURRENT.PROVIDER_ID, query.getProviderIds());
+ appendInCondition(conditions, WITHDRAWAL_TXN_CURRENT.TERMINAL_ID, query.getTerminalIds());
+ appendInCondition(conditions, latestSessionTrxId, query.getTrxIds());
+ appendInCondition(conditions, WITHDRAWAL_TXN_CURRENT.CURRENCY, query.getCurrencies());
+ appendInCondition(conditions, WITHDRAWAL_TXN_CURRENT.STATUS, query.getStatuses());
+ var filter = query.getFilter();
+ appendSearchCondition(
+ conditions,
+ WALLET_LOOKUP.WALLET_SEARCH,
+ filter == null ? null : filter.getWalletTerm()
+ );
+ appendSearchCondition(
+ conditions,
+ PROVIDER_LOOKUP.PROVIDER_SEARCH,
+ filter == null ? null : filter.getProviderTerm()
+ );
+ appendSearchCondition(
+ conditions,
+ TERMINAL_LOOKUP.TERMINAL_SEARCH,
+ filter == null ? null : filter.getTerminalTerm()
+ );
+ appendSearchCondition(
+ conditions,
+ latestSessionTrxSearch,
+ filter == null ? null : filter.getTrxTerm()
+ );
+ return conditions;
+ }
+
+ private void appendInCondition(List conditions, Field field, List values) {
+ if (values == null || values.isEmpty()) {
+ return;
+ }
+ conditions.add(field.in(values));
+ }
+
+ private void appendSearchCondition(List conditions, Field field, String value) {
+ if (value == null || value.isBlank()) {
+ return;
+ }
+ var normalizedValue = normalize(value);
+ var pattern = "%" + escapeLikeLiteral(normalizedValue) + "%";
+ conditions.add(DSL.condition("{0} LIKE {1} ESCAPE '!'", field, DSL.val(pattern)));
+ }
+
+ private String escapeLikeLiteral(String value) {
+ return value
+ .replace("!", "!!")
+ .replace("%", "!%")
+ .replace("_", "!_");
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/ReportLifecycleDao.java b/src/main/java/dev/vality/ccreporter/dao/ReportLifecycleDao.java
new file mode 100644
index 0000000..f08f2f9
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/ReportLifecycleDao.java
@@ -0,0 +1,188 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.domain.enums.ReportStatus;
+import dev.vality.ccreporter.domain.tables.pojos.ReportFile;
+import dev.vality.ccreporter.model.ReportTask;
+import lombok.RequiredArgsConstructor;
+import org.jooq.DSLContext;
+import org.jooq.Field;
+import org.jooq.impl.DSL;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import static dev.vality.ccreporter.domain.Tables.REPORT_FILE;
+import static dev.vality.ccreporter.domain.Tables.REPORT_JOB;
+import static dev.vality.ccreporter.util.TimestampUtils.toLocalDateTime;
+
+@Repository
+@RequiredArgsConstructor
+public class ReportLifecycleDao {
+
+ private static final Field CANDIDATE_ID = DSL.field(DSL.name("candidate", "id"), Long.class);
+ private static final String WORKER_TIMEOUT_CODE = "worker_timeout";
+ private static final String WORKER_TIMEOUT_MESSAGE = "Report processing exceeded maximum duration";
+
+ private final DSLContext dslContext;
+
+ public Optional claimNextPendingReport(Instant now) {
+ var candidate = DSL.table(DSL.name("candidate"));
+ var claimTime = toLocalDateTime(now);
+ return dslContext.with("candidate").as(
+ dslContext.select(REPORT_JOB.ID)
+ .from(REPORT_JOB)
+ .where(REPORT_JOB.STATUS.eq(ReportStatus.pending))
+ .and(REPORT_JOB.NEXT_ATTEMPT_AT.isNull()
+ .or(REPORT_JOB.NEXT_ATTEMPT_AT.le(claimTime)))
+ .orderBy(REPORT_JOB.CREATED_AT.asc(), REPORT_JOB.ID.asc())
+ .limit(1)
+ .forUpdate()
+ .skipLocked()
+ )
+ .update(REPORT_JOB)
+ .set(REPORT_JOB.STATUS, ReportStatus.processing)
+ .set(REPORT_JOB.ATTEMPT, REPORT_JOB.ATTEMPT.plus(1))
+ .set(REPORT_JOB.STARTED_AT, claimTime)
+ .set(REPORT_JOB.NEXT_ATTEMPT_AT, (LocalDateTime) null)
+ .set(REPORT_JOB.ERROR_CODE, (String) null)
+ .set(REPORT_JOB.ERROR_MESSAGE, (String) null)
+ .from(candidate)
+ .where(REPORT_JOB.ID.eq(CANDIDATE_ID))
+ .returning(
+ REPORT_JOB.ID,
+ REPORT_JOB.REPORT_TYPE,
+ REPORT_JOB.QUERY_JSON,
+ REPORT_JOB.TIMEZONE,
+ REPORT_JOB.ATTEMPT
+ )
+ .fetchOptional(record -> new ReportTask(
+ record.get(REPORT_JOB.ID),
+ record.get(REPORT_JOB.REPORT_TYPE),
+ record.get(REPORT_JOB.QUERY_JSON).data(),
+ record.get(REPORT_JOB.TIMEZONE),
+ record.get(REPORT_JOB.ATTEMPT)
+ ));
+ }
+
+ public boolean cancelReport(String createdBy, long reportId, Instant now) {
+ return dslContext.update(REPORT_JOB)
+ .set(REPORT_JOB.STATUS, ReportStatus.canceled)
+ .set(REPORT_JOB.FINISHED_AT, toLocalDateTime(now))
+ .set(REPORT_JOB.NEXT_ATTEMPT_AT, (LocalDateTime) null)
+ .where(REPORT_JOB.ID.eq(reportId))
+ .and(REPORT_JOB.CREATED_BY.eq(createdBy))
+ .and(REPORT_JOB.STATUS.in(ReportStatus.pending, ReportStatus.processing))
+ .execute() == 1;
+ }
+
+ public boolean rescheduleForRetry(long reportId, Instant nextAttemptAt, String errorCode, String errorMessage) {
+ return dslContext.update(REPORT_JOB)
+ .set(REPORT_JOB.STATUS, ReportStatus.pending)
+ .set(REPORT_JOB.STARTED_AT, (LocalDateTime) null)
+ .set(REPORT_JOB.NEXT_ATTEMPT_AT, toLocalDateTime(nextAttemptAt))
+ .set(REPORT_JOB.ERROR_CODE, errorCode)
+ .set(REPORT_JOB.ERROR_MESSAGE, errorMessage)
+ .where(REPORT_JOB.ID.eq(reportId))
+ .and(REPORT_JOB.STATUS.eq(ReportStatus.processing))
+ .execute() == 1;
+ }
+
+ public boolean markFailed(long reportId, Instant finishedAt, String code, String message) {
+ return dslContext.update(REPORT_JOB)
+ .set(REPORT_JOB.STATUS, ReportStatus.failed)
+ .set(REPORT_JOB.FINISHED_AT, toLocalDateTime(finishedAt))
+ .set(REPORT_JOB.ERROR_CODE, code)
+ .set(REPORT_JOB.ERROR_MESSAGE, message)
+ .set(REPORT_JOB.NEXT_ATTEMPT_AT, (LocalDateTime) null)
+ .where(REPORT_JOB.ID.eq(reportId))
+ .and(REPORT_JOB.STATUS.eq(ReportStatus.processing))
+ .execute() == 1;
+ }
+
+ @Transactional
+ public boolean completeReport(
+ long reportId,
+ ReportFile reportFile,
+ Instant dataSnapshotFixedAt,
+ Instant finishedAt,
+ Instant expiresAt,
+ long rowsCount
+ ) {
+ var completed = dslContext.update(REPORT_JOB)
+ .set(REPORT_JOB.STATUS, ReportStatus.created)
+ .set(REPORT_JOB.DATA_SNAPSHOT_FIXED_AT, toLocalDateTime(dataSnapshotFixedAt))
+ .set(REPORT_JOB.FINISHED_AT, toLocalDateTime(finishedAt))
+ .set(REPORT_JOB.EXPIRES_AT, toLocalDateTime(expiresAt))
+ .set(REPORT_JOB.ROWS_COUNT, rowsCount)
+ .set(REPORT_JOB.ERROR_CODE, (String) null)
+ .set(REPORT_JOB.ERROR_MESSAGE, (String) null)
+ .set(REPORT_JOB.NEXT_ATTEMPT_AT, (LocalDateTime) null)
+ .where(REPORT_JOB.ID.eq(reportId))
+ .and(REPORT_JOB.STATUS.eq(ReportStatus.processing))
+ .execute();
+ if (completed == 0) {
+ return false;
+ }
+
+ dslContext.insertInto(REPORT_FILE)
+ .columns(
+ REPORT_FILE.REPORT_ID,
+ REPORT_FILE.FILE_ID,
+ REPORT_FILE.FILE_TYPE,
+ REPORT_FILE.FILENAME,
+ REPORT_FILE.CONTENT_TYPE,
+ REPORT_FILE.SIZE_BYTES,
+ REPORT_FILE.MD5,
+ REPORT_FILE.SHA256,
+ REPORT_FILE.CREATED_AT
+ )
+ .values(
+ reportId,
+ reportFile.getFileId(),
+ reportFile.getFileType(),
+ reportFile.getFilename(),
+ reportFile.getContentType(),
+ reportFile.getSizeBytes(),
+ reportFile.getMd5(),
+ reportFile.getSha256(),
+ toLocalDateTime(finishedAt)
+ )
+ .execute();
+ return true;
+ }
+
+ public int timeoutStaleProcessingReports(Instant staleBefore, Instant finishedAt) {
+ return dslContext.update(REPORT_JOB)
+ .set(REPORT_JOB.STATUS, ReportStatus.timed_out)
+ .set(REPORT_JOB.FINISHED_AT, toLocalDateTime(finishedAt))
+ .set(REPORT_JOB.ERROR_CODE, WORKER_TIMEOUT_CODE)
+ .set(REPORT_JOB.ERROR_MESSAGE, WORKER_TIMEOUT_MESSAGE)
+ .set(REPORT_JOB.NEXT_ATTEMPT_AT, (LocalDateTime) null)
+ .where(REPORT_JOB.STATUS.eq(ReportStatus.processing))
+ .and(REPORT_JOB.STARTED_AT.le(toLocalDateTime(staleBefore)))
+ .execute();
+ }
+
+ public boolean markTimedOut(long reportId, Instant finishedAt) {
+ return dslContext.update(REPORT_JOB)
+ .set(REPORT_JOB.STATUS, ReportStatus.timed_out)
+ .set(REPORT_JOB.FINISHED_AT, toLocalDateTime(finishedAt))
+ .set(REPORT_JOB.ERROR_CODE, WORKER_TIMEOUT_CODE)
+ .set(REPORT_JOB.ERROR_MESSAGE, WORKER_TIMEOUT_MESSAGE)
+ .set(REPORT_JOB.NEXT_ATTEMPT_AT, (LocalDateTime) null)
+ .where(REPORT_JOB.ID.eq(reportId))
+ .and(REPORT_JOB.STATUS.eq(ReportStatus.processing))
+ .execute() == 1;
+ }
+
+ public int expireReports(Instant now) {
+ return dslContext.update(REPORT_JOB)
+ .set(REPORT_JOB.STATUS, ReportStatus.expired)
+ .where(REPORT_JOB.STATUS.eq(ReportStatus.created))
+ .and(REPORT_JOB.EXPIRES_AT.le(toLocalDateTime(now)))
+ .execute();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/ReportQueryDao.java b/src/main/java/dev/vality/ccreporter/dao/ReportQueryDao.java
new file mode 100644
index 0000000..2d4cf81
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/ReportQueryDao.java
@@ -0,0 +1,126 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.GetReportsFilter;
+import dev.vality.ccreporter.dao.mapper.ReportRecordMapper;
+import dev.vality.ccreporter.domain.enums.ReportStatus;
+import dev.vality.ccreporter.model.DownloadableFile;
+import dev.vality.ccreporter.model.ReportProjection;
+import dev.vality.ccreporter.serde.json.ContinuationTokenJsonSerializer.PageCursor;
+import lombok.RequiredArgsConstructor;
+import org.jooq.Condition;
+import org.jooq.DSLContext;
+import org.jooq.Record;
+import org.jooq.SelectJoinStep;
+import org.springframework.stereotype.Repository;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import static dev.vality.ccreporter.domain.Tables.REPORT_FILE;
+import static dev.vality.ccreporter.domain.Tables.REPORT_JOB;
+import static dev.vality.ccreporter.util.TimestampUtils.*;
+
+@Repository
+@RequiredArgsConstructor
+public class ReportQueryDao {
+
+ private final DSLContext dslContext;
+
+ public Optional getReport(String createdBy, long reportId) {
+ return baseReportSelect()
+ .where(REPORT_JOB.ID.eq(reportId))
+ .and(REPORT_JOB.CREATED_BY.eq(createdBy))
+ .fetchOptional(ReportRecordMapper::mapReportProjection);
+ }
+
+ public List getReports(String createdBy, GetReportsFilter filter, PageCursor cursor, int limit) {
+ return baseReportSelect()
+ .where(buildReportConditions(createdBy, filter, cursor))
+ .orderBy(REPORT_JOB.CREATED_AT.desc(), REPORT_JOB.ID.desc())
+ .limit(limit)
+ .fetch(ReportRecordMapper::mapReportProjection);
+ }
+
+ public Optional getDownloadableFile(String createdBy, String fileId, Instant now) {
+ return dslContext.select(REPORT_FILE.fields())
+ .select(REPORT_JOB.EXPIRES_AT)
+ .from(REPORT_FILE)
+ .join(REPORT_JOB).on(REPORT_JOB.ID.eq(REPORT_FILE.REPORT_ID))
+ .where(REPORT_FILE.FILE_ID.eq(fileId))
+ .and(REPORT_JOB.CREATED_BY.eq(createdBy))
+ .and(REPORT_JOB.STATUS.eq(ReportStatus.created))
+ .and(REPORT_JOB.EXPIRES_AT.gt(toLocalDateTime(now)))
+ .fetchOptional(record -> new DownloadableFile(
+ ReportRecordMapper.mapReportFile(record),
+ toInstant(record.get(REPORT_JOB.EXPIRES_AT))
+ ));
+ }
+
+ private List buildReportConditions(String createdBy, GetReportsFilter filter, PageCursor cursor) {
+ var conditions = new ArrayList();
+ conditions.add(REPORT_JOB.CREATED_BY.eq(createdBy));
+
+ if (filter != null && filter.isSetStatuses() && !filter.getStatuses().isEmpty()) {
+ conditions.add(
+ REPORT_JOB.STATUS.in(
+ filter.getStatuses().stream()
+ .map(status -> ReportRecordMapper.mapEnum(
+ status,
+ dev.vality.ccreporter.domain.enums.ReportStatus.class
+ ))
+ .toList()
+ )
+ );
+ }
+ if (filter != null && filter.isSetReportTypes() && !filter.getReportTypes().isEmpty()) {
+ conditions.add(
+ REPORT_JOB.REPORT_TYPE.in(
+ filter.getReportTypes().stream()
+ .map(reportType -> ReportRecordMapper.mapEnum(
+ reportType,
+ dev.vality.ccreporter.domain.enums.ReportType.class
+ ))
+ .toList()
+ )
+ );
+ }
+ if (filter != null && filter.isSetFileTypes() && !filter.getFileTypes().isEmpty()) {
+ conditions.add(
+ REPORT_JOB.FILE_TYPE.in(
+ filter.getFileTypes().stream()
+ .map(fileType -> ReportRecordMapper.mapEnum(
+ fileType,
+ dev.vality.ccreporter.domain.enums.FileType.class
+ ))
+ .toList()
+ )
+ );
+ }
+ if (filter != null && filter.isSetCreatedFrom()) {
+ conditions.add(REPORT_JOB.CREATED_AT.ge(toLocalDateTime(parse(filter.getCreatedFrom()))));
+ }
+ if (filter != null && filter.isSetCreatedTo()) {
+ conditions.add(REPORT_JOB.CREATED_AT.le(toLocalDateTime(parse(filter.getCreatedTo()))));
+ }
+ if (cursor != null) {
+ conditions.add(
+ REPORT_JOB.CREATED_AT.lt(toLocalDateTime(cursor.createdAt()))
+ .or(
+ REPORT_JOB.CREATED_AT.eq(toLocalDateTime(cursor.createdAt()))
+ .and(REPORT_JOB.ID.lt(cursor.reportId()))
+ )
+ );
+ }
+ return conditions;
+ }
+
+ private SelectJoinStep baseReportSelect() {
+ return dslContext.select(REPORT_JOB.fields())
+ .select(REPORT_FILE.fields())
+ .from(REPORT_JOB)
+ .leftJoin(REPORT_FILE).on(REPORT_FILE.REPORT_ID.eq(REPORT_JOB.ID));
+ }
+
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/WithdrawalSessionDao.java b/src/main/java/dev/vality/ccreporter/dao/WithdrawalSessionDao.java
new file mode 100644
index 0000000..773ad49
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/WithdrawalSessionDao.java
@@ -0,0 +1,48 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.domain.tables.pojos.WithdrawalSession;
+import lombok.RequiredArgsConstructor;
+import org.jooq.DSLContext;
+import org.jooq.Field;
+import org.springframework.stereotype.Repository;
+
+import java.util.Map;
+import java.util.Set;
+
+import static dev.vality.ccreporter.dao.support.DaoUpsertUtils.*;
+import static dev.vality.ccreporter.domain.Tables.WITHDRAWAL_SESSION;
+
+@Repository
+@RequiredArgsConstructor
+public class WithdrawalSessionDao {
+
+ private static final Set> IMMUTABLE_FIELDS = Set.of(
+ WITHDRAWAL_SESSION.SESSION_ID,
+ WITHDRAWAL_SESSION.UPDATED_AT
+ );
+
+ private static final Set> OVERWRITE_FIELDS = Set.of(
+ WITHDRAWAL_SESSION.DOMAIN_EVENT_ID,
+ WITHDRAWAL_SESSION.DOMAIN_EVENT_CREATED_AT
+ );
+
+ private final DSLContext dslContext;
+
+ public void upsert(WithdrawalSession update) {
+ var record = dslContext.newRecord(WITHDRAWAL_SESSION, update);
+ record.changed(WITHDRAWAL_SESSION.UPDATED_AT, false);
+
+ dslContext.insertInto(WITHDRAWAL_SESSION)
+ .set(record)
+ .onConflict(WITHDRAWAL_SESSION.SESSION_ID)
+ .doUpdate()
+ .set(buildUpsertMap(
+ WITHDRAWAL_SESSION,
+ IMMUTABLE_FIELDS,
+ OVERWRITE_FIELDS,
+ Map.of(WITHDRAWAL_SESSION.UPDATED_AT, UTC_NOW)
+ ))
+ .where(isIncomingEventNewer(WITHDRAWAL_SESSION.DOMAIN_EVENT_ID))
+ .execute();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/WithdrawalTxnCurrentDao.java b/src/main/java/dev/vality/ccreporter/dao/WithdrawalTxnCurrentDao.java
new file mode 100644
index 0000000..5408a65
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/WithdrawalTxnCurrentDao.java
@@ -0,0 +1,61 @@
+package dev.vality.ccreporter.dao;
+
+import dev.vality.ccreporter.domain.tables.pojos.WithdrawalTxnCurrent;
+import lombok.RequiredArgsConstructor;
+import org.jooq.DSLContext;
+import org.jooq.Field;
+import org.jooq.impl.DSL;
+import org.springframework.stereotype.Repository;
+
+import java.util.Map;
+import java.util.Set;
+
+import static dev.vality.ccreporter.dao.support.DaoUpsertUtils.*;
+import static dev.vality.ccreporter.domain.Tables.WITHDRAWAL_TXN_CURRENT;
+
+@Repository
+@RequiredArgsConstructor
+public class WithdrawalTxnCurrentDao {
+
+ private static final Set> IMMUTABLE_FIELDS = Set.of(
+ WITHDRAWAL_TXN_CURRENT.WITHDRAWAL_ID
+ );
+
+ private static final Set> OVERWRITE_FIELDS = Set.of(
+ WITHDRAWAL_TXN_CURRENT.DOMAIN_EVENT_ID,
+ WITHDRAWAL_TXN_CURRENT.DOMAIN_EVENT_CREATED_AT
+ );
+
+ private final DSLContext dslContext;
+
+ public void upsert(WithdrawalTxnCurrent update) {
+ var record = dslContext.newRecord(WITHDRAWAL_TXN_CURRENT, update);
+ record.changed(WITHDRAWAL_TXN_CURRENT.UPDATED_AT, false);
+
+ dslContext.insertInto(WITHDRAWAL_TXN_CURRENT)
+ .set(record)
+ .onConflict(WITHDRAWAL_TXN_CURRENT.WITHDRAWAL_ID)
+ .doUpdate()
+ .set(buildUpsertMap(
+ WITHDRAWAL_TXN_CURRENT,
+ IMMUTABLE_FIELDS,
+ OVERWRITE_FIELDS,
+ Map.of(
+ WITHDRAWAL_TXN_CURRENT.UPDATED_AT,
+ UTC_NOW,
+ WITHDRAWAL_TXN_CURRENT.FINALIZED_AT,
+ DSL.when(
+ DSL.excluded(WITHDRAWAL_TXN_CURRENT.STATUS).isNotNull(),
+ DSL.excluded(WITHDRAWAL_TXN_CURRENT.FINALIZED_AT)
+ ).otherwise(WITHDRAWAL_TXN_CURRENT.FINALIZED_AT),
+ WITHDRAWAL_TXN_CURRENT.ERROR_SUMMARY,
+ DSL.when(
+ DSL.excluded(WITHDRAWAL_TXN_CURRENT.STATUS).isNotNull(),
+ DSL.excluded(WITHDRAWAL_TXN_CURRENT.ERROR_SUMMARY)
+ ).otherwise(WITHDRAWAL_TXN_CURRENT.ERROR_SUMMARY)
+ )
+ ))
+ .where(isIncomingEventNewer(WITHDRAWAL_TXN_CURRENT.DOMAIN_EVENT_ID))
+ .execute();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/mapper/ReportAuditMapper.java b/src/main/java/dev/vality/ccreporter/dao/mapper/ReportAuditMapper.java
new file mode 100644
index 0000000..d9aca05
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/mapper/ReportAuditMapper.java
@@ -0,0 +1,54 @@
+package dev.vality.ccreporter.dao.mapper;
+
+import com.fasterxml.jackson.annotation.JsonUnwrapped;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.vality.ccreporter.domain.tables.pojos.ReportAuditEvent;
+import dev.vality.ccreporter.domain.tables.records.ReportAuditEventRecord;
+import dev.vality.ccreporter.model.RequestAuditMetadata;
+import lombok.RequiredArgsConstructor;
+import org.jooq.DSLContext;
+import org.jooq.JSONB;
+import org.springframework.stereotype.Component;
+
+import static dev.vality.ccreporter.domain.Tables.REPORT_AUDIT_EVENT;
+
+@Component
+@RequiredArgsConstructor
+public class ReportAuditMapper {
+
+ private final ObjectMapper objectMapper;
+
+ public ReportAuditEventRecord newInsertableRecord(
+ DSLContext dslContext,
+ long reportId,
+ String eventType,
+ String actor,
+ RequestAuditMetadata metadata,
+ Object details
+ ) {
+ var auditEvent = new ReportAuditEvent()
+ .setReportId(reportId)
+ .setEventType(eventType)
+ .setActor(actor)
+ .setPayloadJson(JSONB.jsonb(serializePayload(metadata, details)));
+ var record = dslContext.newRecord(REPORT_AUDIT_EVENT, auditEvent);
+ record.changed(REPORT_AUDIT_EVENT.ID, false);
+ record.changed(REPORT_AUDIT_EVENT.CREATED_AT, false);
+ return record;
+ }
+
+ private String serializePayload(RequestAuditMetadata metadata, Object details) {
+ try {
+ return objectMapper.writeValueAsString(new AuditPayload(metadata, details));
+ } catch (JsonProcessingException ex) {
+ throw new IllegalStateException("Failed to serialize report audit payload", ex);
+ }
+ }
+
+ private record AuditPayload(
+ @JsonUnwrapped RequestAuditMetadata metadata,
+ Object details
+ ) {
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/mapper/ReportRecordMapper.java b/src/main/java/dev/vality/ccreporter/dao/mapper/ReportRecordMapper.java
new file mode 100644
index 0000000..42f3c6d
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/mapper/ReportRecordMapper.java
@@ -0,0 +1,36 @@
+package dev.vality.ccreporter.dao.mapper;
+
+import dev.vality.ccreporter.domain.tables.pojos.ReportFile;
+import dev.vality.ccreporter.domain.tables.pojos.ReportJob;
+import dev.vality.ccreporter.model.ReportProjection;
+import lombok.experimental.UtilityClass;
+import org.jooq.Record;
+
+import static dev.vality.ccreporter.domain.Tables.REPORT_FILE;
+import static dev.vality.ccreporter.domain.Tables.REPORT_JOB;
+
+@UtilityClass
+public class ReportRecordMapper {
+
+ public static ReportProjection mapReportProjection(Record record) {
+ return new ReportProjection(
+ mapReportJob(record),
+ record.get(REPORT_FILE.FILE_ID) == null ? null : mapReportFile(record)
+ );
+ }
+
+ public static ReportJob mapReportJob(org.jooq.Record record) {
+ return record.into(REPORT_JOB).into(ReportJob.class);
+ }
+
+ public static ReportFile mapReportFile(Record record) {
+ return record.into(REPORT_FILE).into(ReportFile.class);
+ }
+
+ public static , T extends Enum> T mapEnum(S source, Class targetClass) {
+ if (source == null) {
+ return null;
+ }
+ return Enum.valueOf(targetClass, source.name());
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/dao/support/DaoUpsertUtils.java b/src/main/java/dev/vality/ccreporter/dao/support/DaoUpsertUtils.java
new file mode 100644
index 0000000..1a7ec8a
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/dao/support/DaoUpsertUtils.java
@@ -0,0 +1,65 @@
+package dev.vality.ccreporter.dao.support;
+
+import lombok.experimental.UtilityClass;
+import org.jooq.Field;
+import org.jooq.Table;
+import org.jooq.TableField;
+import org.jooq.impl.DSL;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@UtilityClass
+public class DaoUpsertUtils {
+
+ public static final Field UTC_NOW =
+ DSL.field("(now() AT TIME ZONE 'utc')", LocalDateTime.class);
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public static Map, Object> buildUpsertMap(
+ Table> table,
+ Set> immutableFields,
+ Set> overwriteFields,
+ Map, Object> explicitAssignments
+ ) {
+ var result = new LinkedHashMap, Object>();
+ for (var field : table.fields()) {
+ if (!(field instanceof TableField, ?> tableField)) {
+ continue;
+ }
+ if (immutableFields.contains(field)) {
+ continue;
+ }
+ if (overwriteFields.contains(field)) {
+ result.put(field, DSL.excluded((TableField) tableField));
+ } else {
+ result.put(field, DSL.coalesce(DSL.excluded((TableField) tableField), field));
+ }
+ }
+ result.putAll(explicitAssignments);
+ return result;
+ }
+
+ public static Map, Object> buildLookupUpsertMap(
+ Table> table,
+ Field> idField,
+ Field updatedAtField
+ ) {
+ return buildUpsertMap(
+ table,
+ Set.of(idField, updatedAtField),
+ Arrays.stream(table.fields())
+ .filter(field -> !field.equals(idField) && !field.equals(updatedAtField))
+ .collect(Collectors.toSet()),
+ Map.of(updatedAtField, UTC_NOW)
+ );
+ }
+
+ public static org.jooq.Condition isIncomingEventNewer(Field eventIdField) {
+ return DSL.excluded(eventIdField).gt(eventIdField);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/dominant/DominantLookupIngestionService.java b/src/main/java/dev/vality/ccreporter/ingestion/dominant/DominantLookupIngestionService.java
new file mode 100644
index 0000000..c6ad084
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/dominant/DominantLookupIngestionService.java
@@ -0,0 +1,146 @@
+package dev.vality.ccreporter.ingestion.dominant;
+
+import dev.vality.ccreporter.dao.DominantLookupDao;
+import dev.vality.damsel.domain.DomainObject;
+import dev.vality.damsel.domain.Reference;
+import dev.vality.damsel.domain_config_v2.FinalOperation;
+import dev.vality.damsel.domain_config_v2.HistoricalCommit;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Comparator;
+import java.util.List;
+
+import static dev.vality.ccreporter.dao.DominantLookupDao.LookupType.*;
+
+@Service
+@RequiredArgsConstructor
+public class DominantLookupIngestionService {
+
+ private final DominantLookupDao dominantLookupDao;
+
+ @Transactional
+ public void handleCommits(List commits) {
+ commits.stream()
+ .sorted(Comparator.comparingLong(HistoricalCommit::getVersion))
+ .forEach(this::handleCommit);
+ }
+
+ private void handleCommit(HistoricalCommit commit) {
+ if (commit == null || commit.getOps() == null) {
+ return;
+ }
+ for (FinalOperation operation : commit.getOps()) {
+ if (operation.isSetInsert()) {
+ upsertObject(operation.getInsert().getObject(), commit.getVersion());
+ } else if (operation.isSetUpdate()) {
+ upsertObject(operation.getUpdate().getObject(), commit.getVersion());
+ } else if (operation.isSetRemove()) {
+ removeObject(operation.getRemove().getRef(), commit.getVersion());
+ }
+ }
+ }
+
+ private void upsertObject(DomainObject object, long version) {
+ if (object == null) {
+ return;
+ }
+ if (object.isSetShopConfig()) {
+ var shop = object.getShopConfig();
+ if (shop.getRef() != null && shop.getData() != null) {
+ dominantLookupDao.upsert(
+ SHOP,
+ shop.getRef().getId(),
+ shop.getData().getName(),
+ version,
+ false
+ );
+ }
+ return;
+ }
+ if (object.isSetProvider()) {
+ var provider = object.getProvider();
+ if (provider.getRef() != null && provider.getData() != null) {
+ dominantLookupDao.upsert(
+ PROVIDER,
+ String.valueOf(provider.getRef().getId()),
+ provider.getData().getName(),
+ version,
+ false
+ );
+ }
+ return;
+ }
+ if (object.isSetTerminal()) {
+ var terminal = object.getTerminal();
+ if (terminal.getRef() != null && terminal.getData() != null) {
+ dominantLookupDao.upsert(
+ TERMINAL,
+ String.valueOf(terminal.getRef().getId()),
+ terminal.getData().getName(),
+ version,
+ false
+ );
+ }
+ return;
+ }
+ if (object.isSetWalletConfig()) {
+ var wallet = object.getWalletConfig();
+ if (wallet.getRef() != null && wallet.getData() != null) {
+ dominantLookupDao.upsert(
+ WALLET,
+ wallet.getRef().getId(),
+ wallet.getData().getName(),
+ version,
+ false
+ );
+ }
+ }
+ }
+
+ private void removeObject(Reference reference, long version) {
+ if (reference == null) {
+ return;
+ }
+ if (reference.isSetShopConfig()) {
+ dominantLookupDao.upsert(
+ SHOP,
+ reference.getShopConfig().getId(),
+ null,
+ version,
+ true
+ );
+ return;
+ }
+ if (reference.isSetProvider()) {
+ dominantLookupDao.upsert(
+ PROVIDER,
+ String.valueOf(reference.getProvider().getId()),
+ null,
+ version,
+ true
+ );
+ return;
+ }
+ if (reference.isSetTerminal()) {
+ dominantLookupDao.upsert(
+ TERMINAL,
+ String.valueOf(reference.getTerminal().getId()),
+ null,
+ version,
+ true
+ );
+ return;
+ }
+ if (reference.isSetWalletConfig()) {
+ dominantLookupDao.upsert(
+ WALLET,
+ reference.getWalletConfig().getId(),
+ null,
+ version,
+ true
+ );
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/payment/PaymentEventProjector.java b/src/main/java/dev/vality/ccreporter/ingestion/payment/PaymentEventProjector.java
new file mode 100644
index 0000000..bf226e4
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/payment/PaymentEventProjector.java
@@ -0,0 +1,246 @@
+package dev.vality.ccreporter.ingestion.payment;
+
+import dev.vality.ccreporter.domain.tables.pojos.PaymentTxnCurrent;
+import dev.vality.ccreporter.ingestion.payment.support.PaymentToolExtractor;
+import dev.vality.ccreporter.ingestion.payment.support.ProxyStateExtractor;
+import dev.vality.ccreporter.ingestion.payment.support.TransactionExtraExtractor;
+import dev.vality.ccreporter.ingestion.shared.cashflow.CashFlowAmountExtractor;
+import dev.vality.damsel.domain.InvoicePaymentStatus;
+import dev.vality.damsel.payment_processing.EventPayload;
+import dev.vality.damsel.payment_processing.InvoiceChange;
+import dev.vality.damsel.payment_processing.InvoicePaymentChange;
+import dev.vality.damsel.payment_processing.SessionChangePayload;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+import static dev.vality.ccreporter.ingestion.shared.status.StatusDetailExtractor.*;
+import static dev.vality.ccreporter.util.SearchValueNormalizer.normalize;
+import static dev.vality.ccreporter.util.TimestampUtils.toLocalDateTime;
+import static dev.vality.ccreporter.util.TimestampUtils.toNullableLocalDateTime;
+
+@Component
+@RequiredArgsConstructor
+public class PaymentEventProjector {
+
+ private final ProxyStateExtractor proxyStateExtractor;
+
+ public List project(MachineEvent event, EventPayload payload) {
+ if (payload == null || !payload.isSetInvoiceChanges()) {
+ return List.of();
+ }
+ var updatesByPaymentId = new LinkedHashMap();
+ for (InvoiceChange change : payload.getInvoiceChanges()) {
+ if (change.isSetInvoicePaymentChange()) {
+ projectPaymentChange(event, change).ifPresent(update -> updatesByPaymentId.merge(
+ update.getPaymentId(),
+ update,
+ this::mergeUpdates
+ ));
+ }
+ }
+ return List.copyOf(updatesByPaymentId.values());
+ }
+
+ private Optional projectPaymentChange(MachineEvent event, InvoiceChange change) {
+ var paymentChange = change.getInvoicePaymentChange();
+ if (paymentChange == null || paymentChange.getPayload() == null) {
+ return Optional.empty();
+ }
+
+ return paymentStartedUpdate(event, paymentChange)
+ .or(() -> paymentRouteChangedUpdate(event, paymentChange))
+ .or(() -> paymentCashChangedUpdate(event, paymentChange))
+ .or(() -> paymentCashFlowChangedUpdate(event, paymentChange))
+ .or(() -> paymentStatusChangedUpdate(event, paymentChange))
+ .or(() -> paymentTransactionBoundUpdate(event, paymentChange))
+ .or(() -> paymentProxyStateFallbackUpdate(event, paymentChange));
+ }
+
+ private Optional paymentStartedUpdate(
+ MachineEvent event,
+ InvoicePaymentChange paymentChange
+ ) {
+ if (!paymentChange.getPayload().isSetInvoicePaymentStarted()) {
+ return Optional.empty();
+ }
+ var started = paymentChange.getPayload().getInvoicePaymentStarted();
+ var payment = started.getPayment();
+ var cost = payment.getCost();
+ var route = started.getRoute();
+ return Optional.of(baseUpdate(event, paymentChange)
+ .setPartyId(payment.isSetPartyRef() ? payment.getPartyRef().getId() : null)
+ .setShopId(payment.isSetShopRef() ? payment.getShopRef().getId() : null)
+ .setCreatedAt(toLocalDateTime(payment.getCreatedAt()))
+ .setStatus(PENDING_STATUS)
+ .setProviderId(route != null ? String.valueOf(route.getProvider().getId()) : null)
+ .setTerminalId(route != null ? String.valueOf(route.getTerminal().getId()) : null)
+ .setAmount(cost.getAmount())
+ .setCurrency(cost.getCurrency().getSymbolicCode())
+ .setExternalId(payment.getExternalId())
+ .setPaymentToolType(PaymentToolExtractor.extractPaymentToolType(payment))
+ .setOriginalAmount(cost.getAmount())
+ .setOriginalCurrency(cost.getCurrency().getSymbolicCode()));
+ }
+
+ private Optional paymentRouteChangedUpdate(
+ MachineEvent event,
+ InvoicePaymentChange paymentChange
+ ) {
+ if (!paymentChange.getPayload().isSetInvoicePaymentRouteChanged()) {
+ return Optional.empty();
+ }
+ var route = paymentChange.getPayload().getInvoicePaymentRouteChanged().getRoute();
+ return Optional.of(baseUpdate(event, paymentChange)
+ .setProviderId(String.valueOf(route.getProvider().getId()))
+ .setTerminalId(String.valueOf(route.getTerminal().getId())));
+ }
+
+ private Optional paymentCashChangedUpdate(
+ MachineEvent event,
+ InvoicePaymentChange paymentChange
+ ) {
+ if (!paymentChange.getPayload().isSetInvoicePaymentCashChanged()) {
+ return Optional.empty();
+ }
+ var cash = paymentChange.getPayload().getInvoicePaymentCashChanged().getNewCash();
+ return Optional.of(baseUpdate(event, paymentChange)
+ .setAmount(cash.getAmount())
+ .setCurrency(cash.getCurrency().getSymbolicCode()));
+ }
+
+ private Optional paymentCashFlowChangedUpdate(
+ MachineEvent event,
+ InvoicePaymentChange paymentChange
+ ) {
+ if (!paymentChange.getPayload().isSetInvoicePaymentCashFlowChanged()) {
+ return Optional.empty();
+ }
+ var postings = paymentChange.getPayload().getInvoicePaymentCashFlowChanged().getCashFlow();
+ return Optional.of(baseUpdate(event, paymentChange)
+ .setAmount(CashFlowAmountExtractor.extractPaymentAmount(postings))
+ .setFee(CashFlowAmountExtractor.extractPaymentFee(postings)));
+ }
+
+ private Optional paymentStatusChangedUpdate(
+ MachineEvent event,
+ InvoicePaymentChange paymentChange
+ ) {
+ if (!paymentChange.getPayload().isSetInvoicePaymentStatusChanged()) {
+ return Optional.empty();
+ }
+ var status = paymentChange.getPayload().getInvoicePaymentStatusChanged().getStatus();
+ var capturedCost = extractCapturedCost(status);
+ return Optional.of(baseUpdate(event, paymentChange)
+ .setFinalizedAt(
+ toNullableLocalDateTime(terminalFinalizedAt(status, Instant.parse(event.getCreatedAt()))))
+ .setStatus(status.getSetField().getFieldName())
+ .setErrorSummary(extractErrorSummary(status))
+ .setProviderAmount(capturedCost != null ? capturedCost.getAmount() : null)
+ .setProviderCurrency(extractSymbolicCode(capturedCost)));
+ }
+
+ private Optional paymentTransactionBoundUpdate(
+ MachineEvent event,
+ InvoicePaymentChange paymentChange
+ ) {
+ var sessionPayload = sessionPayload(paymentChange);
+ if (sessionPayload == null || !sessionPayload.isSetSessionTransactionBound()) {
+ return Optional.empty();
+ }
+ var trx = sessionPayload.getSessionTransactionBound().getTrx();
+ var info = trx.getAdditionalInfo();
+ var code = info != null ? info.getApprovalCode() : null;
+ var rrn = info != null ? info.getRrn() : null;
+ return Optional.of(baseUpdate(event, paymentChange)
+ .setTrxId(trx.getId())
+ .setRrn(rrn)
+ .setApprovalCode(code)
+ .setConvertedAmount(TransactionExtraExtractor.getConvertedAmount(trx))
+ .setExchangeRateInternal(TransactionExtraExtractor.getExchangeRate(trx))
+ .setTrxSearch(normalize(trx.getId(), rrn, code)));
+ }
+
+ private Optional paymentProxyStateFallbackUpdate(
+ MachineEvent event,
+ InvoicePaymentChange paymentChange
+ ) {
+ var sessionPayload = sessionPayload(paymentChange);
+ if (sessionPayload == null || !sessionPayload.isSetSessionProxyStateChanged()) {
+ return Optional.empty();
+ }
+ var trxId = proxyStateExtractor.extractProviderTrxId(sessionPayload);
+ if (trxId == null) {
+ return Optional.empty();
+ }
+ return Optional.of(baseUpdate(event, paymentChange)
+ .setTrxId(trxId)
+ .setTrxSearch(normalize(trxId, null, null)));
+ }
+
+ private PaymentTxnCurrent baseUpdate(MachineEvent event, InvoicePaymentChange paymentChange) {
+ return new PaymentTxnCurrent()
+ .setInvoiceId(event.getSourceId())
+ .setPaymentId(paymentChange.getId())
+ .setDomainEventId(event.getEventId())
+ .setDomainEventCreatedAt(toLocalDateTime(event.getCreatedAt()));
+ }
+
+ private PaymentTxnCurrent mergeUpdates(PaymentTxnCurrent accumulated, PaymentTxnCurrent update) {
+ copyIfPresent(update.getPartyId(), accumulated::setPartyId);
+ copyIfPresent(update.getShopId(), accumulated::setShopId);
+ copyIfPresent(update.getCreatedAt(), accumulated::setCreatedAt);
+ if (update.getStatus() != null) {
+ accumulated.setStatus(update.getStatus());
+ accumulated.setFinalizedAt(update.getFinalizedAt());
+ accumulated.setErrorSummary(update.getErrorSummary());
+ }
+ copyIfPresent(update.getProviderId(), accumulated::setProviderId);
+ copyIfPresent(update.getTerminalId(), accumulated::setTerminalId);
+ copyIfPresent(update.getAmount(), accumulated::setAmount);
+ copyIfPresent(update.getFee(), accumulated::setFee);
+ copyIfPresent(update.getCurrency(), accumulated::setCurrency);
+ copyIfPresent(update.getTrxId(), accumulated::setTrxId);
+ copyIfPresent(update.getExternalId(), accumulated::setExternalId);
+ copyIfPresent(update.getRrn(), accumulated::setRrn);
+ copyIfPresent(update.getApprovalCode(), accumulated::setApprovalCode);
+ copyIfPresent(update.getPaymentToolType(), accumulated::setPaymentToolType);
+ copyIfPresent(update.getOriginalAmount(), accumulated::setOriginalAmount);
+ copyIfPresent(update.getOriginalCurrency(), accumulated::setOriginalCurrency);
+ copyIfPresent(update.getConvertedAmount(), accumulated::setConvertedAmount);
+ copyIfPresent(update.getExchangeRateInternal(), accumulated::setExchangeRateInternal);
+ copyIfPresent(update.getProviderAmount(), accumulated::setProviderAmount);
+ copyIfPresent(update.getProviderCurrency(), accumulated::setProviderCurrency);
+ copyIfPresent(update.getTrxSearch(), accumulated::setTrxSearch);
+ return accumulated;
+ }
+
+ private void copyIfPresent(T value, Consumer setter) {
+ if (value != null) {
+ setter.accept(value);
+ }
+ }
+
+ private SessionChangePayload sessionPayload(InvoicePaymentChange paymentChange) {
+ if (!paymentChange.getPayload().isSetInvoicePaymentSessionChange()) {
+ return null;
+ }
+ return paymentChange.getPayload().getInvoicePaymentSessionChange().getPayload();
+ }
+
+ private Instant terminalFinalizedAt(InvoicePaymentStatus status, Instant eventCreatedAt) {
+ if (status == null || status.getSetField() == null) {
+ return null;
+ }
+ return switch (status.getSetField()) {
+ case CAPTURED, CANCELLED, FAILED, REFUNDED, CHARGED_BACK -> eventCreatedAt;
+ default -> null;
+ };
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/payment/PaymentIngestionService.java b/src/main/java/dev/vality/ccreporter/ingestion/payment/PaymentIngestionService.java
new file mode 100644
index 0000000..ed3eea2
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/payment/PaymentIngestionService.java
@@ -0,0 +1,33 @@
+package dev.vality.ccreporter.ingestion.payment;
+
+import dev.vality.ccreporter.dao.PaymentTxnCurrentDao;
+import dev.vality.ccreporter.serde.thrift.MachineEventParser;
+import dev.vality.damsel.payment_processing.EventPayload;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Comparator;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class PaymentIngestionService {
+
+ private final PaymentTxnCurrentDao paymentTxnCurrentDao;
+ private final PaymentEventProjector paymentEventProjector;
+ private final MachineEventParser paymentEventPayloadMachineEventParser;
+
+ @Transactional
+ public void handleEvents(List machineEvents) {
+ machineEvents.stream()
+ .sorted(Comparator.comparingLong(MachineEvent::getEventId))
+ .forEach(this::handleEvent);
+ }
+
+ private void handleEvent(MachineEvent event) {
+ var payload = paymentEventPayloadMachineEventParser.parse(event);
+ paymentEventProjector.project(event, payload).forEach(paymentTxnCurrentDao::upsert);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/payment/support/PaymentToolExtractor.java b/src/main/java/dev/vality/ccreporter/ingestion/payment/support/PaymentToolExtractor.java
new file mode 100644
index 0000000..f5db8e6
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/payment/support/PaymentToolExtractor.java
@@ -0,0 +1,22 @@
+package dev.vality.ccreporter.ingestion.payment.support;
+
+import dev.vality.damsel.domain.InvoicePayment;
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class PaymentToolExtractor {
+
+ public static String extractPaymentToolType(InvoicePayment payment) {
+ if (payment == null || !payment.isSetPayer()) {
+ return null;
+ }
+ var payer = payment.getPayer();
+ if (payer.isSetPaymentResource()
+ && payer.getPaymentResource().isSetResource()
+ && payer.getPaymentResource().getResource().isSetPaymentTool()
+ && payer.getPaymentResource().getResource().getPaymentTool().getSetField() != null) {
+ return payer.getPaymentResource().getResource().getPaymentTool().getSetField().getFieldName();
+ }
+ return payer.getSetField() != null ? payer.getSetField().getFieldName() : null;
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/payment/support/ProxyStateExtractor.java b/src/main/java/dev/vality/ccreporter/ingestion/payment/support/ProxyStateExtractor.java
new file mode 100644
index 0000000..5faded2
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/payment/support/ProxyStateExtractor.java
@@ -0,0 +1,36 @@
+package dev.vality.ccreporter.ingestion.payment.support;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.vality.damsel.payment_processing.SessionChangePayload;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+@Component
+@RequiredArgsConstructor
+public class ProxyStateExtractor {
+
+ private final ObjectMapper objectMapper;
+
+ public String extractProviderTrxId(SessionChangePayload payload) {
+ if (payload == null || !payload.isSetSessionProxyStateChanged()) {
+ return null;
+ }
+ var proxyStateChanged = payload.getSessionProxyStateChanged();
+ if (proxyStateChanged.getProxyState() == null) {
+ return null;
+ }
+ try {
+ var proxyStateJson = objectMapper.readTree(new String(
+ proxyStateChanged.getProxyState(),
+ StandardCharsets.UTF_8
+ ));
+ var providerTrxId = proxyStateJson.path("providerTrxId").asText(null);
+ return providerTrxId != null ? providerTrxId : proxyStateJson.path("trxId").asText(null);
+ } catch (IOException ex) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/payment/support/TransactionExtraExtractor.java b/src/main/java/dev/vality/ccreporter/ingestion/payment/support/TransactionExtraExtractor.java
new file mode 100644
index 0000000..9d91627
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/payment/support/TransactionExtraExtractor.java
@@ -0,0 +1,55 @@
+package dev.vality.ccreporter.ingestion.payment.support;
+
+import dev.vality.damsel.domain.TransactionInfo;
+import lombok.experimental.UtilityClass;
+
+import java.math.BigDecimal;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+
+@UtilityClass
+public class TransactionExtraExtractor {
+
+ public static final String CONVERTED_AMOUNT_KEY = "converted_amount";
+ public static final String EXCHANGE_RATE_KEY = "_rate";
+
+ public static Long getConvertedAmount(TransactionInfo trx) {
+ return getValue(trx, CONVERTED_AMOUNT_KEY, Long::parseLong);
+ }
+
+ public static BigDecimal getExchangeRate(TransactionInfo trx) {
+ return getValue(trx, EXCHANGE_RATE_KEY, BigDecimal::new);
+ }
+
+ public static T getValue(TransactionInfo trx, String keySuffix, Function parser) {
+ if (trx == null || trx.getExtra() == null || trx.getExtra().isEmpty()) {
+ return null;
+ }
+ return trx.getExtra().entrySet().stream()
+ .filter(entry -> matchesKey(entry.getKey(), keySuffix))
+ .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
+ .map(Map.Entry::getValue)
+ .map(value -> parseValue(value, parser))
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .findFirst()
+ .orElse(null);
+ }
+
+ public static boolean matchesKey(String key, String keySuffix) {
+ return key != null && (key.equals(keySuffix) || key.endsWith(keySuffix) || key.contains(keySuffix));
+ }
+
+ public static Optional parseValue(String value, Function parser) {
+ if (value == null || value.isBlank()) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.ofNullable(parser.apply(value));
+ } catch (RuntimeException ex) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/shared/cashflow/CashFlowAmountExtractor.java b/src/main/java/dev/vality/ccreporter/ingestion/shared/cashflow/CashFlowAmountExtractor.java
new file mode 100644
index 0000000..d7ea70b
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/shared/cashflow/CashFlowAmountExtractor.java
@@ -0,0 +1,57 @@
+package dev.vality.ccreporter.ingestion.shared.cashflow;
+
+import dev.vality.damsel.domain.MerchantCashFlowAccount;
+import dev.vality.damsel.domain.ProviderCashFlowAccount;
+import dev.vality.damsel.domain.SystemCashFlowAccount;
+import lombok.experimental.UtilityClass;
+
+import java.util.List;
+import java.util.function.Predicate;
+
+@UtilityClass
+public class CashFlowAmountExtractor {
+
+ public static Long extractPaymentAmount(List postings) {
+ return sum(
+ postings,
+ posting -> posting.getSource().getAccountType().isSetProvider()
+ && posting.getSource().getAccountType().getProvider() == ProviderCashFlowAccount.settlement
+ && posting.getDestination().getAccountType().isSetMerchant()
+ && posting.getDestination().getAccountType().getMerchant() == MerchantCashFlowAccount.settlement
+ );
+ }
+
+ public static Long extractPaymentFee(List postings) {
+ return sum(
+ postings,
+ posting -> posting.getSource().getAccountType().isSetMerchant()
+ && posting.getSource().getAccountType().getMerchant() == MerchantCashFlowAccount.settlement
+ && posting.getDestination().getAccountType().isSetSystem()
+ && posting.getDestination().getAccountType().getSystem() == SystemCashFlowAccount.settlement
+ );
+ }
+
+ public static Long extractWithdrawalFee(List postings) {
+ return postings == null
+ ? null
+ : postings.stream()
+ .filter(posting -> posting.getSource().getAccountType().isSetWallet()
+ && posting.getDestination().getAccountType().isSetSystem())
+ .map(dev.vality.fistful.cashflow.FinalCashFlowPosting::getVolume)
+ .mapToLong(dev.vality.fistful.base.Cash::getAmount)
+ .sum();
+ }
+
+ private static Long sum(
+ List postings,
+ Predicate filter
+ ) {
+ return postings == null
+ ? null
+ : postings.stream()
+ .filter(filter)
+ .map(dev.vality.damsel.domain.FinalCashFlowPosting::getVolume)
+ .mapToLong(dev.vality.damsel.domain.Cash::getAmount)
+ .sum();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/shared/status/FailureSummaryExtractor.java b/src/main/java/dev/vality/ccreporter/ingestion/shared/status/FailureSummaryExtractor.java
new file mode 100644
index 0000000..90a4d1f
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/shared/status/FailureSummaryExtractor.java
@@ -0,0 +1,63 @@
+package dev.vality.ccreporter.ingestion.shared.status;
+
+import dev.vality.damsel.domain.Failure;
+import dev.vality.damsel.domain.OperationFailure;
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class FailureSummaryExtractor {
+
+ public static String summary(OperationFailure operationFailure) {
+ if (operationFailure == null) {
+ return null;
+ }
+ if (operationFailure.isSetOperationTimeout()) {
+ return "operation_timeout";
+ }
+ if (!operationFailure.isSetFailure()) {
+ return null;
+ }
+ var failure = operationFailure.getFailure();
+ var codes = codes(failure);
+ var reason = failure.getReason();
+ if (reason == null || reason.isBlank()) {
+ return codes;
+ }
+ return codes == null ? reason : codes + " | " + reason;
+ }
+
+ public static String summary(dev.vality.fistful.base.Failure failure) {
+ var codes = codes(failure);
+ var reason = failure.getReason();
+ if (reason == null || reason.isBlank()) {
+ return codes;
+ }
+ return codes == null ? reason : codes + " | " + reason;
+ }
+
+ public static String codes(Failure failure) {
+ if (failure == null || failure.getCode() == null || failure.getCode().isBlank()) {
+ return null;
+ }
+ var codes = new StringBuilder(failure.getCode());
+ var subFailure = failure.getSub();
+ while (subFailure != null && subFailure.getCode() != null && !subFailure.getCode().isBlank()) {
+ codes.append(':').append(subFailure.getCode());
+ subFailure = subFailure.getSub();
+ }
+ return codes.toString();
+ }
+
+ public static String codes(dev.vality.fistful.base.Failure failure) {
+ if (failure == null || failure.getCode() == null || failure.getCode().isBlank()) {
+ return null;
+ }
+ var codes = new StringBuilder(failure.getCode());
+ var subFailure = failure.getSub();
+ while (subFailure != null && subFailure.getCode() != null && !subFailure.getCode().isBlank()) {
+ codes.append(':').append(subFailure.getCode());
+ subFailure = subFailure.getSub();
+ }
+ return codes.toString();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/shared/status/StatusDetailExtractor.java b/src/main/java/dev/vality/ccreporter/ingestion/shared/status/StatusDetailExtractor.java
new file mode 100644
index 0000000..38894f6
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/shared/status/StatusDetailExtractor.java
@@ -0,0 +1,39 @@
+package dev.vality.ccreporter.ingestion.shared.status;
+
+import dev.vality.damsel.domain.Cash;
+import dev.vality.damsel.domain.InvoicePaymentStatus;
+import dev.vality.fistful.withdrawal.status.Status;
+import lombok.experimental.UtilityClass;
+
+import static dev.vality.ccreporter.ingestion.shared.status.FailureSummaryExtractor.summary;
+
+@UtilityClass
+public class StatusDetailExtractor {
+
+ public static final String PENDING_STATUS = "pending";
+
+ public static String extractErrorSummary(InvoicePaymentStatus status) {
+ if (status == null || !status.isSetFailed()) {
+ return null;
+ }
+ return summary(status.getFailed().getFailure());
+ }
+
+ public static String extractErrorSummary(Status status) {
+ if (status == null || !status.isSetFailed()) {
+ return null;
+ }
+ return summary(status.getFailed().getFailure());
+ }
+
+ public static Cash extractCapturedCost(InvoicePaymentStatus status) {
+ if (status == null || !status.isSetCaptured()) {
+ return null;
+ }
+ return status.getCaptured().getCost();
+ }
+
+ public static String extractSymbolicCode(Cash cash) {
+ return cash != null && cash.isSetCurrency() ? cash.getCurrency().getSymbolicCode() : null;
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalEventProjector.java b/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalEventProjector.java
new file mode 100644
index 0000000..89aada5
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalEventProjector.java
@@ -0,0 +1,141 @@
+package dev.vality.ccreporter.ingestion.withdrawal;
+
+import dev.vality.ccreporter.domain.tables.pojos.WithdrawalTxnCurrent;
+import dev.vality.ccreporter.ingestion.shared.cashflow.CashFlowAmountExtractor;
+import dev.vality.fistful.withdrawal.Change;
+import dev.vality.fistful.withdrawal.QuoteState;
+import dev.vality.fistful.withdrawal.TimestampedChange;
+import dev.vality.fistful.withdrawal.status.Status;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import static dev.vality.ccreporter.ingestion.shared.status.StatusDetailExtractor.PENDING_STATUS;
+import static dev.vality.ccreporter.ingestion.shared.status.StatusDetailExtractor.extractErrorSummary;
+import static dev.vality.ccreporter.util.TimestampUtils.toLocalDateTime;
+import static dev.vality.ccreporter.util.TimestampUtils.toNullableLocalDateTime;
+
+@Component
+public class WithdrawalEventProjector {
+
+ public List project(MachineEvent event, TimestampedChange payload) {
+ var updates = new ArrayList();
+ if (payload == null || payload.getChange() == null) {
+ return updates;
+ }
+ projectChange(event, payload.getChange()).ifPresent(updates::add);
+ return updates;
+ }
+
+ private Optional projectChange(MachineEvent event, Change change) {
+ return createdUpdate(event, change)
+ .or(() -> bodyChangedUpdate(event, change))
+ .or(() -> routeChangedUpdate(event, change))
+ .or(() -> statusChangedUpdate(event, change))
+ .or(() -> transferCashFlowUpdate(event, change));
+ }
+
+ private Optional createdUpdate(MachineEvent event, Change change) {
+ if (!change.isSetCreated()) {
+ return Optional.empty();
+ }
+ var withdrawal = change.getCreated().getWithdrawal();
+ var body = withdrawal.getBody();
+ var route = withdrawal.getRoute();
+ var quote = withdrawal.getQuote();
+
+ return Optional.of(baseUpdate(event)
+ .setPartyId(withdrawal.getPartyId())
+ .setWalletId(withdrawal.getWalletId())
+ .setDestinationId(withdrawal.getDestinationId())
+ .setCreatedAt(toLocalDateTime(withdrawal.getCreatedAt()))
+ .setStatus(PENDING_STATUS)
+ .setProviderId(route != null ? String.valueOf(route.getProviderId()) : null)
+ .setTerminalId(route != null ? String.valueOf(route.getTerminalId()) : null)
+ .setAmount(body.getAmount())
+ .setCurrency(body.getCurrency().getSymbolicCode())
+ .setExternalId(withdrawal.getExternalId())
+ .setOriginalAmount(quote != null ? quote.getCashFrom().getAmount() : null)
+ .setOriginalCurrency(quote != null ? quote.getCashFrom().getCurrency().getSymbolicCode() : null)
+ .setConvertedAmount(quote != null ? body.getAmount() : null)
+ .setExchangeRateInternal(toRate(quote))
+ .setProviderAmount(quote != null ? quote.getCashTo().getAmount() : null)
+ .setProviderCurrency(quote != null ? quote.getCashTo().getCurrency().getSymbolicCode() : null));
+ }
+
+ private Optional bodyChangedUpdate(MachineEvent event, Change change) {
+ if (!change.isSetBodyChanged()) {
+ return Optional.empty();
+ }
+ var body = change.getBodyChanged().getNewBody();
+ return Optional.of(baseUpdate(event)
+ .setAmount(body.getAmount())
+ .setCurrency(body.getCurrency().getSymbolicCode()));
+ }
+
+ private Optional routeChangedUpdate(MachineEvent event, Change change) {
+ if (!change.isSetRoute()) {
+ return Optional.empty();
+ }
+ var route = change.getRoute().getRoute();
+ return Optional.of(baseUpdate(event)
+ .setProviderId(String.valueOf(route.getProviderId()))
+ .setTerminalId(String.valueOf(route.getTerminalId())));
+ }
+
+ private Optional statusChangedUpdate(MachineEvent event, Change change) {
+ if (!change.isSetStatusChanged()) {
+ return Optional.empty();
+ }
+ var status = change.getStatusChanged().getStatus();
+ return Optional.of(baseUpdate(event)
+ .setStatus(status.getSetField().getFieldName())
+ .setFinalizedAt(
+ toNullableLocalDateTime(terminalFinalizedAt(status, Instant.parse(event.getCreatedAt()))))
+ .setErrorSummary(extractErrorSummary(status)));
+ }
+
+ private Optional transferCashFlowUpdate(MachineEvent event, Change change) {
+ if (!change.isSetTransfer()
+ || !change.getTransfer().isSetPayload()
+ || !change.getTransfer().getPayload().isSetCreated()
+ || !change.getTransfer().getPayload().getCreated().isSetTransfer()
+ || !change.getTransfer().getPayload().getCreated().getTransfer().isSetCashflow()) {
+ return Optional.empty();
+ }
+ var postings = change.getTransfer().getPayload().getCreated().getTransfer().getCashflow().getPostings();
+ return Optional.of(baseUpdate(event)
+ .setFee(CashFlowAmountExtractor.extractWithdrawalFee(postings)));
+ }
+
+ private WithdrawalTxnCurrent baseUpdate(MachineEvent event) {
+ return new WithdrawalTxnCurrent()
+ .setWithdrawalId(event.getSourceId())
+ .setDomainEventId(event.getEventId())
+ .setDomainEventCreatedAt(toLocalDateTime(event.getCreatedAt()));
+ }
+
+ private Instant terminalFinalizedAt(Status status, Instant eventCreatedAt) {
+ if (status == null || status.getSetField() == null) {
+ return null;
+ }
+ return switch (status.getSetField()) {
+ case SUCCEEDED, FAILED -> eventCreatedAt;
+ default -> null;
+ };
+ }
+
+ private BigDecimal toRate(QuoteState quote) {
+ if (quote == null || quote.getCashFrom().getAmount() == 0) {
+ return null;
+ }
+ return BigDecimal.valueOf(quote.getCashTo().getAmount())
+ .divide(BigDecimal.valueOf(quote.getCashFrom().getAmount()), 10, RoundingMode.HALF_UP);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalIngestionService.java b/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalIngestionService.java
new file mode 100644
index 0000000..1d066cf
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalIngestionService.java
@@ -0,0 +1,33 @@
+package dev.vality.ccreporter.ingestion.withdrawal;
+
+import dev.vality.ccreporter.dao.WithdrawalTxnCurrentDao;
+import dev.vality.ccreporter.serde.thrift.MachineEventParser;
+import dev.vality.fistful.withdrawal.TimestampedChange;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Comparator;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class WithdrawalIngestionService {
+
+ private final WithdrawalTxnCurrentDao withdrawalTxnCurrentDao;
+ private final WithdrawalEventProjector withdrawalEventProjector;
+ private final MachineEventParser withdrawalEventMachineEventParser;
+
+ @Transactional
+ public void handleEvents(List machineEvents) {
+ machineEvents.stream()
+ .sorted(Comparator.comparingLong(MachineEvent::getEventId))
+ .forEach(this::handleEvent);
+ }
+
+ private void handleEvent(MachineEvent event) {
+ var payload = withdrawalEventMachineEventParser.parse(event);
+ withdrawalEventProjector.project(event, payload).forEach(withdrawalTxnCurrentDao::upsert);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/session/WithdrawalSessionEventProjector.java b/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/session/WithdrawalSessionEventProjector.java
new file mode 100644
index 0000000..569210d
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/session/WithdrawalSessionEventProjector.java
@@ -0,0 +1,57 @@
+package dev.vality.ccreporter.ingestion.withdrawal.session;
+
+import dev.vality.ccreporter.domain.tables.pojos.WithdrawalSession;
+import dev.vality.fistful.withdrawal_session.Change;
+import dev.vality.fistful.withdrawal_session.TimestampedChange;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import static dev.vality.ccreporter.util.SearchValueNormalizer.normalize;
+import static dev.vality.ccreporter.util.TimestampUtils.toLocalDateTime;
+
+@Component
+public class WithdrawalSessionEventProjector {
+
+ public List project(MachineEvent event, TimestampedChange payload) {
+ var updates = new ArrayList();
+ if (payload == null || payload.getChange() == null) {
+ return updates;
+ }
+ projectChange(event, payload.getChange()).ifPresent(updates::add);
+ return updates;
+ }
+
+ private Optional projectChange(MachineEvent event, Change change) {
+ return createdUpdate(event, change)
+ .or(() -> transactionBoundUpdate(event, change));
+ }
+
+ private Optional createdUpdate(MachineEvent event, Change change) {
+ if (!change.isSetCreated()) {
+ return Optional.empty();
+ }
+ return Optional.of(baseUpdate(event)
+ .setWithdrawalId(change.getCreated().getWithdrawal().getId()));
+ }
+
+ private Optional transactionBoundUpdate(MachineEvent event, Change change) {
+ if (!change.isSetTransactionBound()) {
+ return Optional.empty();
+ }
+ var trxInfo = change.getTransactionBound().getTrxInfo();
+ return Optional.of(baseUpdate(event)
+ .setTrxId(trxInfo.getId())
+ .setTrxSearch(normalize(trxInfo.getId())));
+ }
+
+ private WithdrawalSession baseUpdate(MachineEvent event) {
+ return new WithdrawalSession()
+ .setSessionId(event.getSourceId())
+ .setDomainEventId(event.getEventId())
+ .setDomainEventCreatedAt(toLocalDateTime(event.getCreatedAt()));
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/session/WithdrawalSessionIngestionService.java b/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/session/WithdrawalSessionIngestionService.java
new file mode 100644
index 0000000..4adbc93
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/ingestion/withdrawal/session/WithdrawalSessionIngestionService.java
@@ -0,0 +1,33 @@
+package dev.vality.ccreporter.ingestion.withdrawal.session;
+
+import dev.vality.ccreporter.dao.WithdrawalSessionDao;
+import dev.vality.ccreporter.serde.thrift.MachineEventParser;
+import dev.vality.fistful.withdrawal_session.TimestampedChange;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Comparator;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class WithdrawalSessionIngestionService {
+
+ private final WithdrawalSessionDao withdrawalSessionDao;
+ private final WithdrawalSessionEventProjector withdrawalSessionEventProjector;
+ private final MachineEventParser withdrawalSessionEventMachineEventParser;
+
+ @Transactional
+ public void handleEvents(List machineEvents) {
+ machineEvents.stream()
+ .sorted(Comparator.comparingLong(MachineEvent::getEventId))
+ .forEach(this::handleEvent);
+ }
+
+ private void handleEvent(MachineEvent event) {
+ var payload = withdrawalSessionEventMachineEventParser.parse(event);
+ withdrawalSessionEventProjector.project(event, payload).forEach(withdrawalSessionDao::upsert);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/kafka/listener/DominantEventListener.java b/src/main/java/dev/vality/ccreporter/kafka/listener/DominantEventListener.java
new file mode 100644
index 0000000..b47c3a5
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/kafka/listener/DominantEventListener.java
@@ -0,0 +1,35 @@
+package dev.vality.ccreporter.kafka.listener;
+
+import dev.vality.ccreporter.ingestion.dominant.DominantLookupIngestionService;
+import dev.vality.ccreporter.kafka.support.BatchLoggingKafkaListener;
+import dev.vality.damsel.domain_config_v2.HistoricalCommit;
+import lombok.RequiredArgsConstructor;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "kafka.topics.dominant", name = "enabled", havingValue = "true")
+public class DominantEventListener implements BatchLoggingKafkaListener {
+
+ private final DominantLookupIngestionService dominantLookupIngestionService;
+
+ @KafkaListener(
+ topics = "${kafka.topics.dominant.id}",
+ containerFactory = "dominantKafkaListenerContainerFactory"
+ )
+ public void listen(List> batch, Acknowledgment acknowledgment) {
+ handleBatch(
+ "dominant",
+ batch,
+ acknowledgment,
+ records -> dominantLookupIngestionService.handleCommits(records.stream().map(ConsumerRecord::value)
+ .toList())
+ );
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/kafka/listener/PaymentEventListener.java b/src/main/java/dev/vality/ccreporter/kafka/listener/PaymentEventListener.java
new file mode 100644
index 0000000..8c03108
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/kafka/listener/PaymentEventListener.java
@@ -0,0 +1,36 @@
+package dev.vality.ccreporter.kafka.listener;
+
+import dev.vality.ccreporter.ingestion.payment.PaymentIngestionService;
+import dev.vality.ccreporter.kafka.support.BatchLoggingKafkaListener;
+import dev.vality.machinegun.eventsink.SinkEvent;
+import lombok.RequiredArgsConstructor;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "kafka.topics.payments", name = "enabled", havingValue = "true")
+public class PaymentEventListener implements BatchLoggingKafkaListener {
+
+ private final PaymentIngestionService paymentIngestionService;
+
+ @KafkaListener(
+ topics = "${kafka.topics.payments.id}",
+ containerFactory = "paymentsKafkaListenerContainerFactory"
+ )
+ public void listen(List> batch, Acknowledgment acknowledgment) {
+ handleBatch(
+ "payments",
+ batch,
+ acknowledgment,
+ records -> paymentIngestionService.handleEvents(records.stream().map(ConsumerRecord::value)
+ .map(SinkEvent::getEvent)
+ .toList())
+ );
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/kafka/listener/WithdrawalEventListener.java b/src/main/java/dev/vality/ccreporter/kafka/listener/WithdrawalEventListener.java
new file mode 100644
index 0000000..e225a96
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/kafka/listener/WithdrawalEventListener.java
@@ -0,0 +1,36 @@
+package dev.vality.ccreporter.kafka.listener;
+
+import dev.vality.ccreporter.ingestion.withdrawal.WithdrawalIngestionService;
+import dev.vality.ccreporter.kafka.support.BatchLoggingKafkaListener;
+import dev.vality.machinegun.eventsink.SinkEvent;
+import lombok.RequiredArgsConstructor;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "kafka.topics.withdrawals", name = "enabled", havingValue = "true")
+public class WithdrawalEventListener implements BatchLoggingKafkaListener {
+
+ private final WithdrawalIngestionService withdrawalIngestionService;
+
+ @KafkaListener(
+ topics = "${kafka.topics.withdrawals.id}",
+ containerFactory = "withdrawalsKafkaListenerContainerFactory"
+ )
+ public void listen(List> batch, Acknowledgment acknowledgment) {
+ handleBatch(
+ "withdrawals",
+ batch,
+ acknowledgment,
+ records -> withdrawalIngestionService.handleEvents(records.stream().map(ConsumerRecord::value)
+ .map(SinkEvent::getEvent)
+ .toList())
+ );
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/kafka/listener/WithdrawalSessionEventListener.java b/src/main/java/dev/vality/ccreporter/kafka/listener/WithdrawalSessionEventListener.java
new file mode 100644
index 0000000..37e12bb
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/kafka/listener/WithdrawalSessionEventListener.java
@@ -0,0 +1,36 @@
+package dev.vality.ccreporter.kafka.listener;
+
+import dev.vality.ccreporter.ingestion.withdrawal.session.WithdrawalSessionIngestionService;
+import dev.vality.ccreporter.kafka.support.BatchLoggingKafkaListener;
+import dev.vality.machinegun.eventsink.SinkEvent;
+import lombok.RequiredArgsConstructor;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "kafka.topics.withdrawal-sessions", name = "enabled", havingValue = "true")
+public class WithdrawalSessionEventListener implements BatchLoggingKafkaListener {
+
+ private final WithdrawalSessionIngestionService withdrawalSessionIngestionService;
+
+ @KafkaListener(
+ topics = "${kafka.topics.withdrawal-sessions.id}",
+ containerFactory = "withdrawalSessionsKafkaListenerContainerFactory"
+ )
+ public void listen(List> batch, Acknowledgment acknowledgment) {
+ handleBatch(
+ "withdrawal sessions",
+ batch,
+ acknowledgment,
+ records -> withdrawalSessionIngestionService.handleEvents(records.stream().map(ConsumerRecord::value)
+ .map(SinkEvent::getEvent)
+ .toList())
+ );
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/kafka/support/BatchConsumerLogUtil.java b/src/main/java/dev/vality/ccreporter/kafka/support/BatchConsumerLogUtil.java
new file mode 100644
index 0000000..ada4a67
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/kafka/support/BatchConsumerLogUtil.java
@@ -0,0 +1,49 @@
+package dev.vality.ccreporter.kafka.support;
+
+import lombok.experimental.UtilityClass;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@UtilityClass
+public final class BatchConsumerLogUtil {
+
+ public static String toSummaryString(List> records) {
+ if (records.isEmpty()) {
+ return "empty";
+ }
+
+ return records.stream()
+ .collect(Collectors.groupingBy(ConsumerRecord::partition))
+ .values()
+ .stream()
+ .map(BatchConsumerLogUtil::toPartitionSummaryString)
+ .collect(Collectors.joining("; "));
+ }
+
+ private static String toPartitionSummaryString(List> records) {
+ var firstRecord = records.getFirst();
+ var lastRecord = records.getLast();
+ var keySizeSummary = records.stream().mapToLong(ConsumerRecord::serializedKeySize).summaryStatistics();
+ var valueSizeSummary = records.stream().mapToLong(ConsumerRecord::serializedValueSize).summaryStatistics();
+ return String.format(
+ "topic='%s', partition=%d, offset={%d...%d}, createdAt={%s...%s}, " +
+ "keySize={min=%d, max=%d, avg=%.2f}, valueSize={min=%d, max=%d, avg=%.2f}, count=%d",
+ firstRecord.topic(),
+ firstRecord.partition(),
+ firstRecord.offset(),
+ lastRecord.offset(),
+ Instant.ofEpochMilli(firstRecord.timestamp()),
+ Instant.ofEpochMilli(lastRecord.timestamp()),
+ keySizeSummary.getMin(),
+ keySizeSummary.getMax(),
+ keySizeSummary.getAverage(),
+ valueSizeSummary.getMin(),
+ valueSizeSummary.getMax(),
+ valueSizeSummary.getAverage(),
+ records.size()
+ );
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/kafka/support/BatchLoggingKafkaListener.java b/src/main/java/dev/vality/ccreporter/kafka/support/BatchLoggingKafkaListener.java
new file mode 100644
index 0000000..0a3e0ed
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/kafka/support/BatchLoggingKafkaListener.java
@@ -0,0 +1,26 @@
+package dev.vality.ccreporter.kafka.support;
+
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.slf4j.LoggerFactory;
+import org.springframework.kafka.support.Acknowledgment;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+public interface BatchLoggingKafkaListener {
+
+ default void handleBatch(
+ String batchType,
+ List> batch,
+ Acknowledgment acknowledgment,
+ Consumer>> handler
+ ) {
+ handler.accept(batch);
+ LoggerFactory.getLogger(getClass()).info(
+ "Processed {} batch: {}",
+ batchType,
+ BatchConsumerLogUtil.toSummaryString(batch)
+ );
+ acknowledgment.acknowledge();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/model/DownloadableFile.java b/src/main/java/dev/vality/ccreporter/model/DownloadableFile.java
new file mode 100644
index 0000000..c9980eb
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/model/DownloadableFile.java
@@ -0,0 +1,8 @@
+package dev.vality.ccreporter.model;
+
+import dev.vality.ccreporter.domain.tables.pojos.ReportFile;
+
+import java.time.Instant;
+
+public record DownloadableFile(ReportFile file, Instant reportExpiresAt) {
+}
diff --git a/src/main/java/dev/vality/ccreporter/model/GeneratedCsvReport.java b/src/main/java/dev/vality/ccreporter/model/GeneratedCsvReport.java
new file mode 100644
index 0000000..34805c8
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/model/GeneratedCsvReport.java
@@ -0,0 +1,16 @@
+package dev.vality.ccreporter.model;
+
+import java.nio.file.Path;
+import java.time.Instant;
+
+public record GeneratedCsvReport(
+ String fileName,
+ String contentType,
+ Path contentPath,
+ long sizeBytes,
+ String md5,
+ String sha256,
+ long rowsCount,
+ Instant dataSnapshotFixedAt
+) {
+}
diff --git a/src/main/java/dev/vality/ccreporter/model/ReportProjection.java b/src/main/java/dev/vality/ccreporter/model/ReportProjection.java
new file mode 100644
index 0000000..070dd9a
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/model/ReportProjection.java
@@ -0,0 +1,10 @@
+package dev.vality.ccreporter.model;
+
+import dev.vality.ccreporter.domain.tables.pojos.ReportFile;
+import dev.vality.ccreporter.domain.tables.pojos.ReportJob;
+
+public record ReportProjection(
+ ReportJob job,
+ ReportFile file
+) {
+}
diff --git a/src/main/java/dev/vality/ccreporter/model/ReportTask.java b/src/main/java/dev/vality/ccreporter/model/ReportTask.java
new file mode 100644
index 0000000..f532de1
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/model/ReportTask.java
@@ -0,0 +1,12 @@
+package dev.vality.ccreporter.model;
+
+import dev.vality.ccreporter.domain.enums.ReportType;
+
+public record ReportTask(
+ long id,
+ ReportType reportType,
+ String queryJson,
+ String timezone,
+ int attempt
+) {
+}
diff --git a/src/main/java/dev/vality/ccreporter/model/RequestAuditMetadata.java b/src/main/java/dev/vality/ccreporter/model/RequestAuditMetadata.java
new file mode 100644
index 0000000..8ad1607
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/model/RequestAuditMetadata.java
@@ -0,0 +1,12 @@
+package dev.vality.ccreporter.model;
+
+public record RequestAuditMetadata(
+ String userId,
+ String username,
+ String email,
+ String realm,
+ String traceId,
+ String traceparent,
+ String tracestate
+) {
+}
diff --git a/src/main/java/dev/vality/ccreporter/report/ReportAuditService.java b/src/main/java/dev/vality/ccreporter/report/ReportAuditService.java
new file mode 100644
index 0000000..05d98e2
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/report/ReportAuditService.java
@@ -0,0 +1,95 @@
+package dev.vality.ccreporter.report;
+
+import dev.vality.ccreporter.CreateReportRequest;
+import dev.vality.ccreporter.GeneratePresignedUrlRequest;
+import dev.vality.ccreporter.constants.ReportAuditEventType;
+import dev.vality.ccreporter.dao.ReportAuditDao;
+import dev.vality.ccreporter.model.RequestAuditMetadata;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+
+@Service
+@RequiredArgsConstructor
+public class ReportAuditService {
+
+ private final ReportAuditDao reportAuditDao;
+
+ public void writeReportCreated(
+ long reportId,
+ String createdBy,
+ RequestAuditMetadata auditMetadata,
+ CreateReportRequest request,
+ String timezone
+ ) {
+ reportAuditDao.insertEvent(
+ reportId,
+ ReportAuditEventType.REPORT_CREATED.getEventType(),
+ createdBy,
+ auditMetadata,
+ new CreateReportDetails(
+ request.getReportType().name(),
+ request.getFileType().name(),
+ request.getIdempotencyKey(),
+ timezone
+ )
+ );
+ }
+
+ public void writeReportCanceled(
+ long reportId,
+ String createdBy,
+ RequestAuditMetadata auditMetadata,
+ boolean stateChanged
+ ) {
+ reportAuditDao.insertEvent(
+ reportId,
+ ReportAuditEventType.REPORT_CANCELED.getEventType(),
+ createdBy,
+ auditMetadata,
+ new CancelReportDetails(stateChanged)
+ );
+ }
+
+ public void writePresignedUrlGenerated(
+ long reportId,
+ String createdBy,
+ RequestAuditMetadata auditMetadata,
+ GeneratePresignedUrlRequest request,
+ Instant effectiveExpiresAt,
+ String fileId
+ ) {
+ reportAuditDao.insertEvent(
+ reportId,
+ ReportAuditEventType.PRESIGNED_URL_GENERATED.getEventType(),
+ createdBy,
+ auditMetadata,
+ new PresignedUrlGeneratedDetails(
+ fileId,
+ request.isSetRequestedExpiresAt() ? request.getRequestedExpiresAt() : null,
+ effectiveExpiresAt.toString()
+ )
+ );
+ }
+
+ private record CreateReportDetails(
+ String reportType,
+ String fileType,
+ String idempotencyKey,
+ String timezone
+ ) {
+ }
+
+ private record CancelReportDetails(
+ boolean stateChanged
+ ) {
+ }
+
+ private record PresignedUrlGeneratedDetails(
+ String fileId,
+ String requestedExpiresAt,
+ String effectiveExpiresAt
+ ) {
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/report/ReportCsvService.java b/src/main/java/dev/vality/ccreporter/report/ReportCsvService.java
new file mode 100644
index 0000000..916211d
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/report/ReportCsvService.java
@@ -0,0 +1,334 @@
+package dev.vality.ccreporter.report;
+
+import dev.vality.ccreporter.PaymentsQuery;
+import dev.vality.ccreporter.ReportQuery;
+import dev.vality.ccreporter.WithdrawalsQuery;
+import dev.vality.ccreporter.config.properties.ReportProperties;
+import dev.vality.ccreporter.dao.ReportCsvDao;
+import dev.vality.ccreporter.model.GeneratedCsvReport;
+import dev.vality.ccreporter.model.ReportTask;
+import dev.vality.ccreporter.serde.json.ThriftJsonCodec;
+import lombok.RequiredArgsConstructor;
+import org.jooq.Cursor;
+import org.jooq.Record;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Isolation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.BufferedOutputStream;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.DigestOutputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Currency;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CancellationException;
+
+@Service
+@RequiredArgsConstructor
+public class ReportCsvService {
+
+ private static final String CSV_LINE_ENDING = "\r\n";
+ private static final DateTimeFormatter CSV_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
+ private static final DateTimeFormatter CSV_TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
+ private static final String CREATED_DATE_COLUMN = "created_date";
+ private static final String CREATED_TIME_COLUMN = "created_time";
+ private static final String FINALIZED_DATE_COLUMN = "finalized_date";
+ private static final String FINALIZED_TIME_COLUMN = "finalized_time";
+ private static final String INVOICE_ID_COLUMN = "invoice_id";
+ private static final String PAYMENT_ID_COLUMN = "payment_id";
+ private static final String WITHDRAWAL_ID_COLUMN = "withdrawal_id";
+ private static final String STATUS_COLUMN = "status";
+ private static final String AMOUNT_COLUMN = "amount";
+ private static final String CURRENCY_COLUMN = "currency";
+ private static final String TRX_ID_COLUMN = "trx_id";
+ private static final String PROVIDER_ID_COLUMN = "provider_id";
+ private static final String TERMINAL_ID_COLUMN = "terminal_id";
+ private static final String SHOP_ID_COLUMN = "shop_id";
+ private static final String WALLET_ID_COLUMN = "wallet_id";
+ private static final String EXCHANGE_RATE_INTERNAL_COLUMN = "exchange_rate_internal";
+ private static final String PROVIDER_AMOUNT_COLUMN = "provider_amount";
+ private static final String PROVIDER_CURRENCY_COLUMN = "provider_currency";
+ private static final String ORIGINAL_AMOUNT_COLUMN = "original_amount";
+ private static final String ORIGINAL_CURRENCY_COLUMN = "original_currency";
+ private static final String CONVERTED_AMOUNT_COLUMN = "converted_amount";
+
+ private static final List PAYMENT_COLUMNS = List.of(
+ CREATED_DATE_COLUMN,
+ CREATED_TIME_COLUMN,
+ FINALIZED_DATE_COLUMN,
+ FINALIZED_TIME_COLUMN,
+ INVOICE_ID_COLUMN,
+ PAYMENT_ID_COLUMN,
+ STATUS_COLUMN,
+ AMOUNT_COLUMN,
+ CURRENCY_COLUMN,
+ TRX_ID_COLUMN,
+ PROVIDER_ID_COLUMN,
+ TERMINAL_ID_COLUMN,
+ SHOP_ID_COLUMN,
+ EXCHANGE_RATE_INTERNAL_COLUMN,
+ PROVIDER_AMOUNT_COLUMN,
+ PROVIDER_CURRENCY_COLUMN,
+ ORIGINAL_AMOUNT_COLUMN,
+ ORIGINAL_CURRENCY_COLUMN,
+ CONVERTED_AMOUNT_COLUMN
+ );
+
+ private static final List WITHDRAWAL_COLUMNS = List.of(
+ CREATED_DATE_COLUMN,
+ CREATED_TIME_COLUMN,
+ FINALIZED_DATE_COLUMN,
+ FINALIZED_TIME_COLUMN,
+ WITHDRAWAL_ID_COLUMN,
+ STATUS_COLUMN,
+ AMOUNT_COLUMN,
+ CURRENCY_COLUMN,
+ TRX_ID_COLUMN,
+ PROVIDER_ID_COLUMN,
+ TERMINAL_ID_COLUMN,
+ WALLET_ID_COLUMN,
+ EXCHANGE_RATE_INTERNAL_COLUMN,
+ PROVIDER_AMOUNT_COLUMN,
+ PROVIDER_CURRENCY_COLUMN,
+ ORIGINAL_AMOUNT_COLUMN,
+ ORIGINAL_CURRENCY_COLUMN,
+ CONVERTED_AMOUNT_COLUMN
+ );
+
+ private final ReportCsvDao reportCsvDao;
+ private final ThriftJsonCodec thriftJsonCodec;
+ private final ReportProperties reportProperties;
+
+ @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
+ public GeneratedCsvReport generate(ReportTask reportTask) {
+ reportCsvDao.setLocalStatementTimeout(reportProperties.getProcessingTimeoutMs());
+ var snapshotFixedAt = reportCsvDao.currentSnapshot();
+ var reportQuery = thriftJsonCodec.deserialize(reportTask.queryJson(), ReportQuery.class);
+ var zoneId = ZoneId.of(reportTask.timezone());
+ var reportType = reportTask.reportType();
+ var fileName = reportType.name() + "-report-" + reportTask.id() + ".csv";
+ var stagedFile = createTempFile(reportTask.id());
+ try {
+ var md5 = createDigest("MD5");
+ var sha256 = createDigest("SHA-256");
+ long rowsCount;
+ try (
+ var fileOutputStream = Files.newOutputStream(stagedFile);
+ var bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
+ var md5OutputStream = new DigestOutputStream(bufferedOutputStream, md5);
+ var sha256OutputStream = new DigestOutputStream(md5OutputStream, sha256);
+ var writer = new BufferedWriter(
+ new OutputStreamWriter(sha256OutputStream, StandardCharsets.UTF_8)
+ )
+ ) {
+ rowsCount = switch (reportType) {
+ case payments -> writePaymentsCsv(writer, reportQuery.getPayments(), zoneId);
+ case withdrawals -> writeWithdrawalsCsv(writer, reportQuery.getWithdrawals(), zoneId);
+ };
+ }
+ return new GeneratedCsvReport(
+ fileName,
+ "text/csv",
+ stagedFile,
+ Files.size(stagedFile),
+ HexFormat.of().formatHex(md5.digest()),
+ HexFormat.of().formatHex(sha256.digest()),
+ rowsCount,
+ snapshotFixedAt
+ );
+ } catch (IOException ex) {
+ deleteIfExists(stagedFile);
+ throw new IllegalStateException("Failed to render CSV report", ex);
+ } catch (RuntimeException ex) {
+ deleteIfExists(stagedFile);
+ throw ex;
+ }
+ }
+
+ private long writePaymentsCsv(BufferedWriter writer, PaymentsQuery query, ZoneId zoneId) throws IOException {
+ writer.write(String.join(",", PAYMENT_COLUMNS));
+ writer.write(CSV_LINE_ENDING);
+ try (var rows = reportCsvDao.fetchPayments(query)) {
+ return writeRows(writer, rows, PAYMENT_COLUMNS, zoneId);
+ }
+ }
+
+ private long writeWithdrawalsCsv(
+ BufferedWriter writer,
+ WithdrawalsQuery query,
+ ZoneId zoneId
+ ) throws IOException {
+ writer.write(String.join(",", WITHDRAWAL_COLUMNS));
+ writer.write(CSV_LINE_ENDING);
+ try (var rows = reportCsvDao.fetchWithdrawals(query)) {
+ return writeRows(writer, rows, WITHDRAWAL_COLUMNS, zoneId);
+ }
+ }
+
+ private long writeRows(
+ BufferedWriter writer,
+ Cursor extends Record> rows,
+ List columns,
+ ZoneId zoneId
+ ) throws IOException {
+ var rowCount = 0L;
+ for (var row : rows) {
+ throwIfInterrupted();
+ writeRow(writer, row, columns, zoneId);
+ rowCount++;
+ }
+ return rowCount;
+ }
+
+ private void throwIfInterrupted() {
+ if (Thread.currentThread().isInterrupted()) {
+ throw new CancellationException("Report CSV generation was interrupted");
+ }
+ }
+
+ private void writeRow(
+ BufferedWriter writer,
+ Record row,
+ List columns,
+ ZoneId zoneId
+ ) throws IOException {
+ for (int i = 0; i < columns.size(); i++) {
+ if (i > 0) {
+ writer.write(',');
+ }
+ var column = columns.get(i);
+ writer.write(escapeCsv(renderValue(row, column, zoneId)));
+ }
+ writer.write(CSV_LINE_ENDING);
+ }
+
+ private String renderValue(Record row, String column, ZoneId zoneId) {
+ return switch (column) {
+ case CREATED_DATE_COLUMN -> renderTimestampDate(row.get("created_at", LocalDateTime.class), zoneId);
+ case CREATED_TIME_COLUMN -> renderTimestampTime(row.get("created_at", LocalDateTime.class), zoneId);
+ case FINALIZED_DATE_COLUMN -> renderTimestampDate(row.get("finalized_at", LocalDateTime.class), zoneId);
+ case FINALIZED_TIME_COLUMN -> renderTimestampTime(row.get("finalized_at", LocalDateTime.class), zoneId);
+ case AMOUNT_COLUMN -> renderMinorUnits(row.get("amount"), row.get("currency", String.class));
+ case PROVIDER_AMOUNT_COLUMN -> renderMinorUnits(
+ row.get("provider_amount"),
+ firstNonBlank(row.get("provider_currency", String.class), row.get("currency", String.class))
+ );
+ case ORIGINAL_AMOUNT_COLUMN -> renderMinorUnits(
+ row.get("original_amount"),
+ row.get("original_currency", String.class)
+ );
+ case CONVERTED_AMOUNT_COLUMN -> renderMinorUnits(
+ row.get("converted_amount"),
+ row.get("currency", String.class)
+ );
+ default -> renderScalarValue(row.get(column));
+ };
+ }
+
+ private String renderScalarValue(Object value) {
+ if (value == null) {
+ return "";
+ }
+ if (value instanceof BigDecimal bigDecimal) {
+ return bigDecimal.toPlainString();
+ }
+ return value.toString();
+ }
+
+ private String renderTimestampDate(LocalDateTime timestamp, ZoneId zoneId) {
+ if (timestamp == null) {
+ return "";
+ }
+ var localDateTime = timestamp.atZone(ZoneOffset.UTC).withZoneSameInstant(zoneId).toLocalDateTime();
+ return CSV_DATE_FORMATTER.format(localDateTime.toLocalDate());
+ }
+
+ private String renderTimestampTime(LocalDateTime timestamp, ZoneId zoneId) {
+ if (timestamp == null) {
+ return "";
+ }
+ var localDateTime = timestamp.atZone(ZoneOffset.UTC).withZoneSameInstant(zoneId).toLocalDateTime();
+ return CSV_TIME_FORMATTER.format(localDateTime.toLocalTime());
+ }
+
+ private String renderMinorUnits(Object value, String currencyCode) {
+ if (value == null) {
+ return "";
+ }
+ if (!(value instanceof Number number)) {
+ throw new IllegalStateException("Expected numeric minor units for currency-formatted CSV column");
+ }
+ if (currencyCode == null || currencyCode.isBlank()) {
+ return Long.toString(number.longValue());
+ }
+ var exponent = currencyExponent(currencyCode);
+ return BigDecimal.valueOf(number.longValue(), exponent).toPlainString();
+ }
+
+ private int currencyExponent(String currencyCode) {
+ try {
+ var currency = Currency.getInstance(currencyCode.toUpperCase(Locale.ROOT));
+ var exponent = currency.getDefaultFractionDigits();
+ if (exponent < 0) {
+ throw new IllegalStateException("Unsupported currency exponent for " + currencyCode);
+ }
+ return exponent;
+ } catch (IllegalArgumentException ex) {
+ throw new IllegalStateException("Unknown currency code for CSV formatting: " + currencyCode, ex);
+ }
+ }
+
+ private String firstNonBlank(String first, String second) {
+ if (first != null && !first.isBlank()) {
+ return first;
+ }
+ return second;
+ }
+
+ private String escapeCsv(String value) {
+ if (!value.contains(",")
+ && !value.contains("\"")
+ && !value.contains("\n")
+ && !value.contains("\r")) {
+ return value;
+ }
+ return "\"" + value.replace("\"", "\"\"") + "\"";
+ }
+
+ private Path createTempFile(long reportId) {
+ try {
+ return Files.createTempFile("ccr-report-" + reportId + "-", ".csv");
+ } catch (IOException ex) {
+ throw new IllegalStateException("Failed to allocate temp file for report " + reportId, ex);
+ }
+ }
+
+ private MessageDigest createDigest(String algorithm) {
+ try {
+ return MessageDigest.getInstance(algorithm);
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("Failed to initialize " + algorithm + " digest", ex);
+ }
+ }
+
+ private void deleteIfExists(Path stagedFile) {
+ try {
+ Files.deleteIfExists(stagedFile);
+ } catch (IOException ignored) {
+ // Best-effort cleanup for abandoned staged files.
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/report/ReportLifecycleScheduler.java b/src/main/java/dev/vality/ccreporter/report/ReportLifecycleScheduler.java
new file mode 100644
index 0000000..1880485
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/report/ReportLifecycleScheduler.java
@@ -0,0 +1,19 @@
+package dev.vality.ccreporter.report;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(prefix = "scheduler", name = "enabled", havingValue = "true")
+public class ReportLifecycleScheduler {
+
+ private final ReportLifecycleService reportLifecycleService;
+
+ @Scheduled(fixedDelayString = "${scheduler.poll-interval-ms:10000}")
+ public void runLifecycleTick() {
+ reportLifecycleService.runLifecycleTick();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/report/ReportLifecycleService.java b/src/main/java/dev/vality/ccreporter/report/ReportLifecycleService.java
new file mode 100644
index 0000000..f565008
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/report/ReportLifecycleService.java
@@ -0,0 +1,273 @@
+package dev.vality.ccreporter.report;
+
+import dev.vality.ccreporter.config.properties.ReportProperties;
+import dev.vality.ccreporter.dao.ReportLifecycleDao;
+import dev.vality.ccreporter.domain.tables.pojos.ReportFile;
+import dev.vality.ccreporter.model.GeneratedCsvReport;
+import dev.vality.ccreporter.model.ReportTask;
+import dev.vality.ccreporter.storage.FileStorageService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.concurrent.*;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ReportLifecycleService {
+
+ private static final Duration RETRY_BACKOFF = Duration.ofSeconds(30);
+
+ private final ReportLifecycleDao reportLifecycleDao;
+ private final ReportCsvService reportCsvService;
+ private final FileStorageService fileStorageService;
+ private final ReportProperties reportProperties;
+ private final ExecutorService reportWorkerExecutor;
+
+ public void runLifecycleTick() {
+ var now = Instant.now();
+ timeoutStaleProcessingReports(now);
+ expireReadyReports(now);
+ while (!Thread.currentThread().isInterrupted()
+ && processPendingBatch(Instant.now()) == reportProperties.getWorkerConcurrency()) {
+ // Drain ready reports in bounded parallel batches.
+ }
+ }
+
+ public int timeoutStaleProcessingReports(Instant now) {
+ var staleBefore = now.minusMillis(reportProperties.getProcessingTimeoutMs());
+ var timedOutReports = reportLifecycleDao.timeoutStaleProcessingReports(staleBefore, now);
+ if (timedOutReports > 0) {
+ log.warn("Timed out {} stale processing report(s)", timedOutReports);
+ }
+ return timedOutReports;
+ }
+
+ public int expireReadyReports(Instant now) {
+ var expiredReports = reportLifecycleDao.expireReports(now);
+ if (expiredReports > 0) {
+ log.info("Expired {} report(s)", expiredReports);
+ }
+ return expiredReports;
+ }
+
+ public boolean processNextPendingReport(Instant now) {
+ var reportTask = reportLifecycleDao.claimNextPendingReport(now);
+ if (reportTask.isEmpty()) {
+ return false;
+ }
+ var runningReport = startReport(reportTask.get());
+ return runningReport != null && awaitReport(runningReport);
+ }
+
+ private int processPendingBatch(Instant now) {
+ var runningReports = new ArrayList(reportProperties.getWorkerConcurrency());
+ for (int worker = 0; worker < reportProperties.getWorkerConcurrency(); worker++) {
+ var reportTask = reportLifecycleDao.claimNextPendingReport(now);
+ if (reportTask.isEmpty()) {
+ break;
+ }
+ var runningReport = startReport(reportTask.get());
+ if (runningReport == null) {
+ break;
+ }
+ runningReports.add(runningReport);
+ }
+ for (int reportIndex = 0; reportIndex < runningReports.size(); reportIndex++) {
+ if (!awaitReport(runningReports.get(reportIndex))) {
+ cancelRemainingReports(runningReports, reportIndex + 1);
+ break;
+ }
+ }
+ return runningReports.size();
+ }
+
+ private RunningReport startReport(ReportTask reportTask) {
+ try {
+ var processing = reportWorkerExecutor.submit(() -> processReportTask(reportTask));
+ var deadlineNanos = System.nanoTime() +
+ TimeUnit.MILLISECONDS.toNanos(reportProperties.getProcessingTimeoutMs());
+ log.info("Started report {} processing attempt {}", reportTask.id(), reportTask.attempt());
+ return new RunningReport(reportTask, processing, deadlineNanos);
+ } catch (RejectedExecutionException ex) {
+ handleProcessingFailure(reportTask, Instant.now(), ex);
+ return null;
+ }
+ }
+
+ private boolean awaitReport(RunningReport runningReport) {
+ var reportTask = runningReport.reportTask();
+ var processing = runningReport.processing();
+ try {
+ var remainingNanos = runningReport.deadlineNanos() - System.nanoTime();
+ if (remainingNanos <= 0) {
+ throw new TimeoutException("Report processing deadline elapsed");
+ }
+ processing.get(remainingNanos, TimeUnit.NANOSECONDS);
+ return true;
+ } catch (TimeoutException ex) {
+ timeoutReport(reportTask, processing, "maximum processing time exceeded");
+ return true;
+ } catch (CancellationException ex) {
+ timeoutReport(reportTask, processing, "worker execution was canceled");
+ return false;
+ } catch (InterruptedException ex) {
+ timeoutReport(reportTask, processing, "scheduler thread was interrupted");
+ Thread.currentThread().interrupt();
+ return false;
+ } catch (ExecutionException ex) {
+ handleProcessingFailure(reportTask, Instant.now(), ex.getCause());
+ return true;
+ }
+ }
+
+ private void cancelRemainingReports(ArrayList runningReports, int firstReportIndex) {
+ for (int reportIndex = firstReportIndex; reportIndex < runningReports.size(); reportIndex++) {
+ var runningReport = runningReports.get(reportIndex);
+ timeoutReport(
+ runningReport.reportTask(),
+ runningReport.processing(),
+ "scheduler stopped while processing batch"
+ );
+ }
+ }
+
+ private void processReportTask(ReportTask reportTask) {
+ GeneratedCsvReport generatedReport = null;
+ try {
+ generatedReport = reportCsvService.generate(reportTask);
+ throwIfInterrupted();
+ var expiresAt = Instant.now().plusSeconds(reportProperties.getExpirationSec());
+ var fileId = fileStorageService.storeFile(
+ generatedReport.fileName(),
+ generatedReport.contentType(),
+ generatedReport.contentPath(),
+ expiresAt
+ );
+ throwIfInterrupted();
+ var reportFile = buildReportFile(fileId, generatedReport);
+ var finishedAt = Instant.now();
+ var completed = reportLifecycleDao.completeReport(
+ reportTask.id(),
+ reportFile,
+ generatedReport.dataSnapshotFixedAt(),
+ finishedAt,
+ expiresAt,
+ generatedReport.rowsCount()
+ );
+ if (!completed) {
+ log.info(
+ "Report {} changed state while it was being generated; uploaded file will expire",
+ reportTask.id()
+ );
+ } else {
+ log.info(
+ "Completed report {} with {} row(s)",
+ reportTask.id(),
+ generatedReport.rowsCount()
+ );
+ }
+ } finally {
+ deleteStagedFile(generatedReport);
+ }
+ }
+
+ private void timeoutReport(ReportTask reportTask, Future> processing, String reason) {
+ processing.cancel(true);
+ var finishedAt = Instant.now();
+ try {
+ var timedOut = reportLifecycleDao.markTimedOut(reportTask.id(), finishedAt);
+ if (timedOut) {
+ log.warn("Report {} timed out: {}", reportTask.id(), reason);
+ } else {
+ log.info("Report {} changed state before timeout transition", reportTask.id());
+ }
+ } catch (RuntimeException ex) {
+ log.error(
+ "Failed to mark report {} as timed out after worker cancellation",
+ reportTask.id(),
+ ex
+ );
+ }
+ }
+
+ private void handleProcessingFailure(ReportTask reportTask, Instant now, Throwable ex) {
+ var errorCode = "report_processing_error";
+ var errorMessage = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
+ if (reportTask.attempt() >= reportProperties.getMaxAttempts()) {
+ var failed = reportLifecycleDao.markFailed(reportTask.id(), now, errorCode, errorMessage);
+ if (failed) {
+ log.error(
+ "Report {} failed after {} attempt(s): {}",
+ reportTask.id(),
+ reportTask.attempt(),
+ errorMessage,
+ ex
+ );
+ } else {
+ log.info("Report {} changed state before failed transition", reportTask.id());
+ }
+ } else {
+ var nextAttemptAt = now.plus(RETRY_BACKOFF);
+ var rescheduled = reportLifecycleDao.rescheduleForRetry(
+ reportTask.id(),
+ nextAttemptAt,
+ errorCode,
+ errorMessage
+ );
+ if (rescheduled) {
+ log.warn(
+ "Report {} attempt {} failed; next attempt at {}: {}",
+ reportTask.id(),
+ reportTask.attempt(),
+ nextAttemptAt,
+ errorMessage,
+ ex
+ );
+ } else {
+ log.info("Report {} changed state before retry transition", reportTask.id());
+ }
+ }
+ }
+
+ private void throwIfInterrupted() {
+ if (Thread.currentThread().isInterrupted()) {
+ throw new CancellationException("Report processing was interrupted");
+ }
+ }
+
+ private ReportFile buildReportFile(String fileId, GeneratedCsvReport generatedReport) {
+ return new ReportFile()
+ .setFileId(fileId)
+ .setFileType(dev.vality.ccreporter.domain.enums.FileType.csv)
+ .setFilename(generatedReport.fileName())
+ .setContentType(generatedReport.contentType())
+ .setSizeBytes(generatedReport.sizeBytes())
+ .setMd5(generatedReport.md5())
+ .setSha256(generatedReport.sha256());
+ }
+
+ private void deleteStagedFile(GeneratedCsvReport generatedReport) {
+ if (generatedReport == null) {
+ return;
+ }
+ try {
+ Files.deleteIfExists(generatedReport.contentPath());
+ } catch (IOException ex) {
+ log.warn("Failed to delete staged report file {}", generatedReport.contentPath(), ex);
+ }
+ }
+
+ private record RunningReport(
+ ReportTask reportTask,
+ Future> processing,
+ long deadlineNanos
+ ) {
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/report/ReportManagementService.java b/src/main/java/dev/vality/ccreporter/report/ReportManagementService.java
new file mode 100644
index 0000000..bd01300
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/report/ReportManagementService.java
@@ -0,0 +1,173 @@
+package dev.vality.ccreporter.report;
+
+import dev.vality.ccreporter.*;
+import dev.vality.ccreporter.config.properties.CcrApiProperties;
+import dev.vality.ccreporter.config.properties.ReportProperties;
+import dev.vality.ccreporter.dao.ReportCommandDao;
+import dev.vality.ccreporter.dao.ReportLifecycleDao;
+import dev.vality.ccreporter.dao.ReportQueryDao;
+import dev.vality.ccreporter.report.mapper.ReportThriftMapper;
+import dev.vality.ccreporter.security.RequestAuditMetadataResolver;
+import dev.vality.ccreporter.serde.json.ContinuationTokenJsonSerializer;
+import dev.vality.ccreporter.storage.FileStorageService;
+import dev.vality.ccreporter.util.TimestampUtils;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.Instant;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class ReportManagementService {
+
+ private final ReportCommandDao reportCommandDao;
+ private final ReportQueryDao reportQueryDao;
+ private final ReportLifecycleDao reportLifecycleDao;
+ private final ReportAuditService reportAuditService;
+ private final ReportRequestValidator reportRequestValidator;
+ private final ReportThriftMapper reportThriftMapper;
+ private final ContinuationTokenJsonSerializer continuationTokenJsonSerializer;
+ private final RequestAuditMetadataResolver requestAuditMetadataResolver;
+ private final CcrApiProperties apiProperties;
+ private final ReportProperties reportProperties;
+ private final FileStorageService fileStorageService;
+
+ @Transactional
+ public long createReport(CreateReportRequest request) throws InvalidRequest {
+ reportRequestValidator.validateCreate(request);
+ var auditMetadata = requestAuditMetadataResolver.resolve();
+ var timezone = StringUtils.hasText(request.getTimezone()) ? request.getTimezone() : "UTC";
+ var createdBy = auditMetadata.email();
+ var result = reportCommandDao.createReport(
+ createdBy,
+ request.getReportType(),
+ request.getFileType(),
+ request.getQuery(),
+ timezone,
+ request.getIdempotencyKey()
+ );
+ if (result.created()) {
+ reportAuditService.writeReportCreated(result.reportId(), createdBy, auditMetadata, request, timezone);
+ }
+ return result.reportId();
+ }
+
+ @Transactional
+ public Report getReport(GetReportRequest request) throws InvalidRequest, ReportNotFound {
+ if (request == null) {
+ throw invalidRequest("request is required");
+ }
+ var createdBy = requestAuditMetadataResolver.resolve().email();
+ reportLifecycleDao.expireReports(Instant.now());
+ return reportQueryDao.getReport(createdBy, request.getReportId())
+ .map(reportThriftMapper::mapReport)
+ .orElseThrow(ReportNotFound::new);
+ }
+
+ @Transactional
+ public GetReportsResponse getReports(GetReportsRequest request) throws InvalidRequest, BadContinuationToken {
+ var createdBy = requestAuditMetadataResolver.resolve().email();
+ var safeRequest = request == null ? new GetReportsRequest() : request;
+ reportRequestValidator.validateGetReports(safeRequest);
+ reportLifecycleDao.expireReports(Instant.now());
+
+ var meta = safeRequest.getMeta();
+ var limit = resolveLimit(meta);
+ var cursor = meta != null && meta.isSetContinuationToken()
+ ? continuationTokenJsonSerializer.deserialize(meta.getContinuationToken())
+ : null;
+ var storedReports = reportQueryDao.getReports(createdBy, safeRequest.getFilter(), cursor, limit + 1);
+ var hasNextPage = storedReports.size() > limit;
+ var page = hasNextPage ? storedReports.subList(0, limit) : storedReports;
+
+ var response = new GetReportsResponse();
+ response.setReports(page.stream().map(reportThriftMapper::mapReport).toList());
+ if (hasNextPage) {
+ var lastReport = page.getLast();
+ response.setContinuationToken(
+ continuationTokenJsonSerializer.serialize(
+ TimestampUtils.toInstant(lastReport.job().getCreatedAt()),
+ lastReport.job().getId()
+ )
+ );
+ }
+ return response;
+ }
+
+ @Transactional
+ public void cancelReport(CancelReportRequest request) throws InvalidRequest, ReportNotFound {
+ if (request == null) {
+ throw invalidRequest("request is required");
+ }
+ var auditMetadata = requestAuditMetadataResolver.resolve();
+ var createdBy = auditMetadata.email();
+ var updated = reportLifecycleDao.cancelReport(createdBy, request.getReportId(), Instant.now());
+ if (!updated && !reportCommandDao.reportExists(createdBy, request.getReportId())) {
+ throw new ReportNotFound();
+ }
+ reportAuditService.writeReportCanceled(request.getReportId(), createdBy, auditMetadata, updated);
+ }
+
+ public String generatePresignedUrl(GeneratePresignedUrlRequest request) throws InvalidRequest, FileNotFound {
+ if (request == null) {
+ throw invalidRequest("request is required");
+ }
+ var auditMetadata = requestAuditMetadataResolver.resolve();
+ var createdBy = auditMetadata.email();
+ var now = Instant.now();
+ var fileData = reportQueryDao.getDownloadableFile(createdBy, request.getFileId(), now);
+ if (fileData.isEmpty()) {
+ throw new FileNotFound();
+ }
+
+ var downloadableFile = fileData.get();
+ var effectiveExpiresAt = resolveEffectivePresignedUrlExpiresAt(
+ request,
+ downloadableFile.reportExpiresAt(),
+ now
+ );
+ var url = fileStorageService.generateDownloadUrl(
+ downloadableFile.file().getFileId(),
+ effectiveExpiresAt
+ );
+ reportAuditService.writePresignedUrlGenerated(
+ downloadableFile.file().getReportId(),
+ createdBy,
+ auditMetadata,
+ request,
+ effectiveExpiresAt,
+ downloadableFile.file().getFileId()
+ );
+ return url;
+ }
+
+ private Instant resolveEffectivePresignedUrlExpiresAt(
+ GeneratePresignedUrlRequest request,
+ Instant reportExpiresAt,
+ Instant now
+ ) throws InvalidRequest {
+ var ttlCap = now.plusSeconds(reportProperties.getPresignedUrlTtlSec());
+ var requestedExpiresAt = request.isSetRequestedExpiresAt()
+ ? TimestampUtils.parse(request.getRequestedExpiresAt())
+ : ttlCap;
+ if (!requestedExpiresAt.isAfter(now)) {
+ throw invalidRequest("requested_expires_at must be in the future");
+ }
+ var requestAndConfigCap = requestedExpiresAt.isAfter(ttlCap) ? ttlCap : requestedExpiresAt;
+ return requestAndConfigCap.isAfter(reportExpiresAt) ? reportExpiresAt : requestAndConfigCap;
+ }
+
+ private int resolveLimit(GetReportsMeta meta) {
+ if (meta == null || !meta.isSetLimit()) {
+ return apiProperties.getDefaultPageSize();
+ }
+ return Math.min(meta.getLimit(), apiProperties.getMaxPageSize());
+ }
+
+ private InvalidRequest invalidRequest(String error) {
+ return new InvalidRequest(List.of(error));
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/report/ReportQueryService.java b/src/main/java/dev/vality/ccreporter/report/ReportQueryService.java
new file mode 100644
index 0000000..b58ace5
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/report/ReportQueryService.java
@@ -0,0 +1,50 @@
+package dev.vality.ccreporter.report;
+
+import dev.vality.ccreporter.ReportQuery;
+import dev.vality.ccreporter.ReportType;
+import dev.vality.ccreporter.TimeRange;
+import dev.vality.ccreporter.util.TimestampUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.DateTimeException;
+import java.time.Instant;
+
+@Service
+public class ReportQueryService {
+
+ public QuerySpec resolveQuerySpec(ReportQuery query) {
+ if (query == null) {
+ throw new IllegalArgumentException("query is required");
+ }
+ if (query.isSetPayments()) {
+ return new QuerySpec(ReportType.payments, parseTimeRange(query.getPayments().getTimeRange()));
+ }
+ if (query.isSetWithdrawals()) {
+ return new QuerySpec(ReportType.withdrawals, parseTimeRange(query.getWithdrawals().getTimeRange()));
+ }
+ throw new IllegalArgumentException("query must select one branch");
+ }
+
+ private QueryTimeRange parseTimeRange(TimeRange timeRange) {
+ if (timeRange == null
+ || !StringUtils.hasText(timeRange.getFromTime())
+ || !StringUtils.hasText(timeRange.getToTime())) {
+ throw new IllegalArgumentException("time range is required");
+ }
+ try {
+ return new QueryTimeRange(
+ TimestampUtils.parse(timeRange.getFromTime()),
+ TimestampUtils.parse(timeRange.getToTime())
+ );
+ } catch (DateTimeException ex) {
+ throw new IllegalArgumentException("time range must contain ISO-8601 timestamps", ex);
+ }
+ }
+
+ public record QueryTimeRange(Instant from, Instant to) {
+ }
+
+ public record QuerySpec(ReportType reportType, QueryTimeRange timeRange) {
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/report/ReportRequestValidator.java b/src/main/java/dev/vality/ccreporter/report/ReportRequestValidator.java
new file mode 100644
index 0000000..dc8ad39
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/report/ReportRequestValidator.java
@@ -0,0 +1,114 @@
+package dev.vality.ccreporter.report;
+
+import dev.vality.ccreporter.CreateReportRequest;
+import dev.vality.ccreporter.GetReportsRequest;
+import dev.vality.ccreporter.InvalidRequest;
+import dev.vality.ccreporter.util.TimestampUtils;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.time.DateTimeException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+public class ReportRequestValidator {
+
+ private final ReportQueryService reportQueryService;
+
+ public void validateCreate(CreateReportRequest request) throws InvalidRequest {
+ var errors = new ArrayList();
+ if (request == null) {
+ errors.add("request is required");
+ } else {
+ if (!request.isSetReportType()) {
+ errors.add("report_type is required");
+ }
+ if (!request.isSetFileType()) {
+ errors.add("file_type is required");
+ }
+ validateQuery(request, errors);
+ validateTimezone(request.getTimezone(), errors);
+ }
+ if (!errors.isEmpty()) {
+ throw new InvalidRequest(errors);
+ }
+ }
+
+ public void validateGetReports(GetReportsRequest request) throws InvalidRequest {
+ var errors = new ArrayList();
+ var meta = request.getMeta();
+ if (meta != null && meta.isSetLimit() && meta.getLimit() <= 0) {
+ errors.add("meta.limit must be positive");
+ }
+ var filter = request.getFilter();
+ if (filter != null) {
+ var createdFrom = parseFilterTimestamp(
+ filter.isSetCreatedFrom(),
+ filter.getCreatedFrom(),
+ "filter.created_from",
+ errors
+ );
+ var createdTo = parseFilterTimestamp(
+ filter.isSetCreatedTo(),
+ filter.getCreatedTo(),
+ "filter.created_to",
+ errors
+ );
+ if (createdFrom != null && createdTo != null && createdFrom.isAfter(createdTo)) {
+ errors.add("filter.created_from must be before or equal to filter.created_to");
+ }
+ }
+ if (!errors.isEmpty()) {
+ throw new InvalidRequest(errors);
+ }
+ }
+
+ private Instant parseFilterTimestamp(
+ boolean isSet,
+ String value,
+ String fieldName,
+ List errors
+ ) {
+ if (!isSet) {
+ return null;
+ }
+ try {
+ return TimestampUtils.parse(value);
+ } catch (DateTimeException ex) {
+ errors.add(fieldName + " must use ISO-8601 format");
+ return null;
+ }
+ }
+
+ private void validateQuery(CreateReportRequest request, List errors) {
+ ReportQueryService.QuerySpec querySpec;
+ try {
+ querySpec = reportQueryService.resolveQuerySpec(request.getQuery());
+ } catch (IllegalArgumentException ex) {
+ errors.add(ex.getMessage());
+ return;
+ }
+ if (request.isSetReportType() && request.getReportType() != querySpec.reportType()) {
+ errors.add("report_type does not match query branch");
+ }
+ if (!querySpec.timeRange().to().isAfter(querySpec.timeRange().from())) {
+ errors.add("time_range.from_time must be before time_range.to_time");
+ }
+ }
+
+ private void validateTimezone(String timezone, List errors) {
+ if (!StringUtils.hasText(timezone)) {
+ return;
+ }
+ try {
+ ZoneId.of(timezone);
+ } catch (DateTimeException ex) {
+ errors.add("timezone must be a valid IANA timezone");
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/report/mapper/ReportThriftMapper.java b/src/main/java/dev/vality/ccreporter/report/mapper/ReportThriftMapper.java
new file mode 100644
index 0000000..3b126f8
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/report/mapper/ReportThriftMapper.java
@@ -0,0 +1,70 @@
+package dev.vality.ccreporter.report.mapper;
+
+import dev.vality.ccreporter.*;
+import dev.vality.ccreporter.dao.mapper.ReportRecordMapper;
+import dev.vality.ccreporter.domain.tables.pojos.ReportFile;
+import dev.vality.ccreporter.model.ReportProjection;
+import dev.vality.ccreporter.serde.json.ThriftJsonCodec;
+import dev.vality.ccreporter.util.TimestampUtils;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+@Component
+@RequiredArgsConstructor
+public class ReportThriftMapper {
+
+ private final ThriftJsonCodec thriftJsonCodec;
+
+ public Report mapReport(ReportProjection reportProjection) {
+ var reportJob = reportProjection.job();
+ var report = new Report();
+ report.setReportId(reportJob.getId());
+ report.setReportType(ReportRecordMapper.mapEnum(reportJob.getReportType(), ReportType.class));
+ report.setFileType(ReportRecordMapper.mapEnum(reportJob.getFileType(), FileType.class));
+ report.setQuery(thriftJsonCodec.deserialize(reportJob.getQueryJson().data(), ReportQuery.class));
+ report.setCreatedAt(TimestampUtils.format(TimestampUtils.toInstant(reportJob.getCreatedAt())));
+ report.setStatus(ReportRecordMapper.mapEnum(reportJob.getStatus(), ReportStatus.class));
+ setTimestampIfPresent(report::setStartedAt, reportJob.getStartedAt());
+ setTimestampIfPresent(report::setDataSnapshotFixedAt, reportJob.getDataSnapshotFixedAt());
+ setTimestampIfPresent(report::setFinishedAt, reportJob.getFinishedAt());
+ if (reportJob.getRowsCount() != null) {
+ report.setRowsCount(reportJob.getRowsCount());
+ }
+ setTimestampIfPresent(report::setExpiresAt, reportJob.getExpiresAt());
+ if (StringUtils.hasText(reportJob.getErrorCode()) || StringUtils.hasText(reportJob.getErrorMessage())) {
+ report.setError(new ErrorInfo(
+ defaultString(reportJob.getErrorCode()),
+ defaultString(reportJob.getErrorMessage())
+ ));
+ }
+ if (reportProjection.file() != null) {
+ report.setFile(mapFile(reportProjection.file()));
+ }
+ return report;
+ }
+
+ public FileMeta mapFile(ReportFile fileData) {
+ var fileMeta = new FileMeta();
+ fileMeta.setFileId(fileData.getFileId());
+ fileMeta.setFileType(ReportRecordMapper.mapEnum(fileData.getFileType(), FileType.class));
+ fileMeta.setFilename(fileData.getFilename());
+ fileMeta.setContentType(fileData.getContentType());
+ fileMeta.setSignature(new FileSignature(fileData.getMd5(), fileData.getSha256()));
+ if (fileData.getSizeBytes() != null) {
+ fileMeta.setSizeBytes(fileData.getSizeBytes());
+ }
+ fileMeta.setCreatedAt(TimestampUtils.format(TimestampUtils.toInstant(fileData.getCreatedAt())));
+ return fileMeta;
+ }
+
+ private void setTimestampIfPresent(java.util.function.Consumer setter, java.time.LocalDateTime value) {
+ if (value != null) {
+ setter.accept(TimestampUtils.format(TimestampUtils.toInstant(value)));
+ }
+ }
+
+ private String defaultString(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/resource/ReportingHandler.java b/src/main/java/dev/vality/ccreporter/resource/ReportingHandler.java
new file mode 100644
index 0000000..838b5d4
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/resource/ReportingHandler.java
@@ -0,0 +1,70 @@
+package dev.vality.ccreporter.resource;
+
+import dev.vality.ccreporter.*;
+import dev.vality.ccreporter.resource.util.ReportingHandlerLogSupport;
+import dev.vality.ccreporter.resource.util.ThriftLoggingHandler;
+import dev.vality.ccreporter.report.ReportManagementService;
+import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class ReportingHandler implements ReportingSrv.Iface, ThriftLoggingHandler {
+
+ private final ReportManagementService reportManagementService;
+
+ @Override
+ @SneakyThrows
+ public long createReport(CreateReportRequest request) {
+ return handleRequest(
+ "CreateReport",
+ () -> ReportingHandlerLogSupport.summarizeCreateReport(request),
+ () -> reportManagementService.createReport(request),
+ reportId -> "reportId=" + reportId
+ );
+ }
+
+ @Override
+ @SneakyThrows
+ public Report getReport(GetReportRequest request) {
+ return handleRequest(
+ "GetReport",
+ () -> ReportingHandlerLogSupport.summarizeGetReport(request),
+ () -> reportManagementService.getReport(request),
+ ReportingHandlerLogSupport::summarizeReport
+ );
+ }
+
+ @Override
+ @SneakyThrows
+ public GetReportsResponse getReports(GetReportsRequest request) {
+ return handleRequest(
+ "GetReports",
+ () -> ReportingHandlerLogSupport.summarizeGetReports(request),
+ () -> reportManagementService.getReports(request),
+ ReportingHandlerLogSupport::summarizeGetReportsResponse
+ );
+ }
+
+ @Override
+ @SneakyThrows
+ public void cancelReport(CancelReportRequest request) {
+ handleRequest(
+ "CancelReport",
+ () -> ReportingHandlerLogSupport.summarizeCancelReport(request),
+ () -> reportManagementService.cancelReport(request)
+ );
+ }
+
+ @Override
+ @SneakyThrows
+ public String generatePresignedUrl(GeneratePresignedUrlRequest request) {
+ return handleRequest(
+ "GeneratePresignedUrl",
+ () -> ReportingHandlerLogSupport.summarizeGeneratePresignedUrl(request),
+ () -> reportManagementService.generatePresignedUrl(request),
+ url -> "urlGenerated=" + (url != null)
+ );
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/resource/util/ReportingHandlerLogSupport.java b/src/main/java/dev/vality/ccreporter/resource/util/ReportingHandlerLogSupport.java
new file mode 100644
index 0000000..040784b
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/resource/util/ReportingHandlerLogSupport.java
@@ -0,0 +1,69 @@
+package dev.vality.ccreporter.resource.util;
+
+import dev.vality.ccreporter.*;
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class ReportingHandlerLogSupport {
+
+ public static String summarizeCreateReport(CreateReportRequest request) {
+ if (request == null) {
+ return "request=null";
+ }
+ return "reportType=" + request.getReportType() +
+ ", fileType=" + request.getFileType() +
+ ", timezone=" + request.getTimezone() +
+ ", idempotencyKeyPresent=" + request.isSetIdempotencyKey() +
+ ", queryBranch=" + request.getQuery().getSetField().getFieldName();
+ }
+
+ public static String summarizeGetReport(GetReportRequest request) {
+ return request == null ? "request=null" : "reportId=" + request.getReportId();
+ }
+
+ public static String summarizeGetReports(GetReportsRequest request) {
+ if (request == null) {
+ return "request=null";
+ }
+ var meta = request.getMeta();
+ var filter = request.getFilter();
+ return "limit=" + (meta != null && meta.isSetLimit() ? meta.getLimit() : null) +
+ ", continuationTokenPresent=" + (meta != null && meta.isSetContinuationToken()) +
+ ", statusesCount=" + (filter != null && filter.isSetStatuses() ? filter.getStatusesSize() : 0) +
+ ", reportTypesCount=" + (filter != null && filter.isSetReportTypes()
+ ? filter.getReportTypesSize()
+ : 0) +
+ ", fileTypesCount=" + (filter != null && filter.isSetFileTypes() ? filter.getFileTypesSize() : 0) +
+ ", createdFromPresent=" + (filter != null && filter.isSetCreatedFrom()) +
+ ", createdToPresent=" + (filter != null && filter.isSetCreatedTo());
+ }
+
+ public static String summarizeCancelReport(CancelReportRequest request) {
+ return request == null ? "request=null" : "reportId=" + request.getReportId();
+ }
+
+ public static String summarizeGeneratePresignedUrl(GeneratePresignedUrlRequest request) {
+ if (request == null) {
+ return "request=null";
+ }
+ return "fileId=" + request.getFileId() +
+ ", requestedExpiresAtPresent=" + request.isSetRequestedExpiresAt();
+ }
+
+ public static String summarizeReport(Report report) {
+ if (report == null) {
+ return "report=null";
+ }
+ return "reportId=" + report.getReportId() +
+ ", status=" + report.getStatus() +
+ ", filePresent=" + report.isSetFile();
+ }
+
+ public static String summarizeGetReportsResponse(GetReportsResponse response) {
+ if (response == null) {
+ return "response=null";
+ }
+ return "reportsCount=" + response.getReportsSize() +
+ ", continuationTokenPresent=" + response.isSetContinuationToken();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/resource/util/ThriftLoggingHandler.java b/src/main/java/dev/vality/ccreporter/resource/util/ThriftLoggingHandler.java
new file mode 100644
index 0000000..1f34b89
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/resource/util/ThriftLoggingHandler.java
@@ -0,0 +1,53 @@
+package dev.vality.ccreporter.resource.util;
+
+import org.slf4j.LoggerFactory;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+public interface ThriftLoggingHandler {
+
+ default T handleRequest(
+ String method,
+ Supplier requestSummary,
+ ThrowingSupplier handler,
+ Function responseSummary
+ ) throws Exception {
+ var log = LoggerFactory.getLogger(getClass());
+ log.info("Handling thrift {}: {}", method, requestSummary.get());
+ try {
+ var response = handler.call();
+ log.info("Handled thrift {}: {}", method, responseSummary.apply(response));
+ return response;
+ } catch (Exception ex) {
+ log.warn("Failed thrift {}: {}, error={}", method, requestSummary.get(), ex.toString());
+ throw ex;
+ }
+ }
+
+ default void handleRequest(
+ String method,
+ Supplier requestSummary,
+ ThrowingRunnable handler
+ ) throws Exception {
+ var log = LoggerFactory.getLogger(getClass());
+ log.info("Handling thrift {}: {}", method, requestSummary.get());
+ try {
+ handler.run();
+ log.info("Handled thrift {}: void", method);
+ } catch (Exception ex) {
+ log.warn("Failed thrift {}: {}, error={}", method, requestSummary.get(), ex.toString());
+ throw ex;
+ }
+ }
+
+ @FunctionalInterface
+ interface ThrowingRunnable {
+ void run() throws Exception;
+ }
+
+ @FunctionalInterface
+ interface ThrowingSupplier {
+ T call() throws Exception;
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/security/RequestAuditMetadataResolver.java b/src/main/java/dev/vality/ccreporter/security/RequestAuditMetadataResolver.java
new file mode 100644
index 0000000..5140d7a
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/security/RequestAuditMetadataResolver.java
@@ -0,0 +1,83 @@
+package dev.vality.ccreporter.security;
+
+import dev.vality.ccreporter.model.RequestAuditMetadata;
+import dev.vality.woody.api.trace.Metadata;
+import dev.vality.woody.api.trace.context.TraceContext;
+import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit;
+import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit;
+import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit;
+import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanContext;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.util.stream.Collectors;
+
+@Component
+public class RequestAuditMetadataResolver {
+
+ private static final String WOODY_USER_ID = UserIdentityIdExtensionKit.KEY;
+ private static final String WOODY_USERNAME = UserIdentityUsernameExtensionKit.KEY;
+ private static final String WOODY_EMAIL = UserIdentityEmailExtensionKit.KEY;
+ private static final String WOODY_REALM = UserIdentityRealmExtensionKit.KEY;
+
+ public RequestAuditMetadata resolve() {
+ var traceData = TraceContext.getCurrentTraceData();
+ var activeSpan = traceData.getActiveSpan();
+ var metadata = activeSpan.getCustomMetadata();
+ var spanContext = Span.current().getSpanContext();
+ return new RequestAuditMetadata(
+ metadataValue(metadata, WOODY_USER_ID),
+ metadataValue(metadata, WOODY_USERNAME),
+ metadataValue(metadata, WOODY_EMAIL),
+ metadataValue(metadata, WOODY_REALM),
+ resolveTraceId(spanContext, activeSpan.getSpan().getTraceId()),
+ resolveTraceparent(spanContext),
+ resolveTracestate(spanContext)
+ );
+ }
+
+ private String resolveTraceId(SpanContext spanContext, String woodyTraceId) {
+ return spanContext.isValid() ? spanContext.getTraceId() : woodyTraceId;
+ }
+
+ private String resolveTraceparent(SpanContext spanContext) {
+ if (!spanContext.isValid()) {
+ return null;
+ }
+ return "00-%s-%s-%s".formatted(
+ spanContext.getTraceId(),
+ spanContext.getSpanId(),
+ spanContext.getTraceFlags().asHex()
+ );
+ }
+
+ private String resolveTracestate(SpanContext spanContext) {
+ if (!spanContext.isValid() || spanContext.getTraceState().isEmpty()) {
+ return null;
+ }
+ return spanContext.getTraceState().asMap().entrySet().stream()
+ .map(entry -> entry.getKey() + "=" + entry.getValue())
+ .collect(Collectors.joining(","));
+ }
+
+ private String metadataValue(Metadata metadata, String key) {
+ if (metadata == null) {
+ return null;
+ }
+ return normalize(metadata.getValue(key));
+ }
+
+ private String normalize(Object value) {
+ if (value == null) {
+ return null;
+ }
+ var stringValue = value.toString();
+ if (StringUtils.hasText(stringValue)) {
+ return stringValue.trim();
+ }
+ return null;
+ }
+
+}
diff --git a/src/main/java/dev/vality/ccreporter/serde/json/ContinuationTokenJsonSerializer.java b/src/main/java/dev/vality/ccreporter/serde/json/ContinuationTokenJsonSerializer.java
new file mode 100644
index 0000000..f72626a
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/serde/json/ContinuationTokenJsonSerializer.java
@@ -0,0 +1,47 @@
+package dev.vality.ccreporter.serde.json;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.vality.ccreporter.BadContinuationToken;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Base64;
+
+@Component
+@RequiredArgsConstructor
+public class ContinuationTokenJsonSerializer {
+
+ private final ObjectMapper objectMapper;
+
+ public String serialize(Instant createdAt, long reportId) {
+ try {
+ var node = objectMapper.createObjectNode();
+ node.put("created_at", createdAt.toString());
+ node.put("report_id", reportId);
+ var payload = objectMapper.writeValueAsBytes(node);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(payload);
+ } catch (Exception ex) {
+ throw new RuntimeException("Failed to encode continuation token to JSON/Base64", ex);
+ }
+ }
+
+ public PageCursor deserialize(String token) throws BadContinuationToken {
+ try {
+ var decoded = Base64.getUrlDecoder().decode(token.getBytes(StandardCharsets.UTF_8));
+ var rootNode = objectMapper.readTree(decoded);
+ return new PageCursor(
+ Instant.parse(rootNode.get("created_at").asText()),
+ rootNode.get("report_id").asLong()
+ );
+ } catch (Exception ex) {
+ var badContinuationToken = new BadContinuationToken();
+ badContinuationToken.setReason("Malformed continuation token: " + token);
+ throw badContinuationToken;
+ }
+ }
+
+ public record PageCursor(Instant createdAt, long reportId) {
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/serde/json/ThriftJsonCodec.java b/src/main/java/dev/vality/ccreporter/serde/json/ThriftJsonCodec.java
new file mode 100644
index 0000000..d911988
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/serde/json/ThriftJsonCodec.java
@@ -0,0 +1,38 @@
+package dev.vality.ccreporter.serde.json;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.vality.geck.serializer.kit.json.JsonHandler;
+import dev.vality.geck.serializer.kit.json.JsonProcessor;
+import dev.vality.geck.serializer.kit.tbase.TBaseHandler;
+import dev.vality.geck.serializer.kit.tbase.TBaseProcessor;
+import lombok.RequiredArgsConstructor;
+import org.apache.thrift.TBase;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+@RequiredArgsConstructor
+public class ThriftJsonCodec {
+
+ private final ObjectMapper objectMapper;
+
+ public String serialize(TBase value) {
+ try {
+ return new TBaseProcessor().process(value, new JsonHandler()).toString();
+ } catch (IOException ex) {
+ throw new IllegalArgumentException("Failed to serialize thrift object to JSON", ex);
+ }
+ }
+
+ public T deserialize(String json, Class thriftClass) {
+ try {
+ return new JsonProcessor().process(
+ objectMapper.readTree(json),
+ new TBaseHandler<>(thriftClass)
+ );
+ } catch (IOException ex) {
+ throw new IllegalArgumentException("Failed to deserialize thrift object from JSON", ex);
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/serde/thrift/MachineEventParser.java b/src/main/java/dev/vality/ccreporter/serde/thrift/MachineEventParser.java
new file mode 100644
index 0000000..540974d
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/serde/thrift/MachineEventParser.java
@@ -0,0 +1,14 @@
+package dev.vality.ccreporter.serde.thrift;
+
+import dev.vality.machinegun.eventsink.MachineEvent;
+import org.apache.thrift.TBase;
+
+public record MachineEventParser>(ThriftDeserializer deserializer) {
+
+ public T parse(MachineEvent event) {
+ if (event == null || event.getData() == null || !event.getData().isSetBin()) {
+ throw new IllegalArgumentException("MachineEvent does not contain binary payload");
+ }
+ return deserializer.deserialize(event.getData().getBin());
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/serde/thrift/ThriftDeserializer.java b/src/main/java/dev/vality/ccreporter/serde/thrift/ThriftDeserializer.java
new file mode 100644
index 0000000..aff8173
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/serde/thrift/ThriftDeserializer.java
@@ -0,0 +1,66 @@
+package dev.vality.ccreporter.serde.thrift;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.common.serialization.Deserializer;
+import org.apache.thrift.TBase;
+import org.apache.thrift.TDeserializer;
+import org.apache.thrift.TException;
+import org.apache.thrift.protocol.TBinaryProtocol;
+import org.apache.thrift.protocol.TProtocolFactory;
+
+import java.util.Map;
+import java.util.function.Supplier;
+
+@Slf4j
+public class ThriftDeserializer> implements Deserializer {
+
+ private final Supplier factory;
+ private final TProtocolFactory protocolFactory;
+ private final ThreadLocal thriftDeserializer = ThreadLocal.withInitial(this::createDeserializer);
+
+ public ThriftDeserializer(Supplier factory) {
+ this(factory, new TBinaryProtocol.Factory());
+ }
+
+ public ThriftDeserializer(Supplier factory, TProtocolFactory protocolFactory) {
+ this.factory = factory;
+ this.protocolFactory = protocolFactory;
+ }
+
+ @Override
+ public void configure(Map configs, boolean isKey) {
+ log.debug("ThriftDeserializer configure: isKey={}", isKey);
+ }
+
+ @Override
+ public T deserialize(String topic, byte[] data) {
+ if (data == null) {
+ return null;
+ }
+ var instance = factory.get();
+ try {
+ thriftDeserializer.get().deserialize(instance, data);
+ return instance;
+ } catch (TException ex) {
+ log.error("Error when deserializing thrift data from topic: {}", topic, ex);
+ throw new RuntimeException(String.format("Failed to deserialize thrift data from topic: %s", topic), ex);
+ }
+ }
+
+ public T deserialize(byte[] data) {
+ return deserialize("unknown", data);
+ }
+
+ @Override
+ public void close() {
+ thriftDeserializer.remove();
+ }
+
+ private TDeserializer createDeserializer() {
+ try {
+ return new TDeserializer(protocolFactory);
+ } catch (TException ex) {
+ throw new RuntimeException("Failed to initialize Apache Thrift TDeserializer", ex);
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/storage/FileStorageClientService.java b/src/main/java/dev/vality/ccreporter/storage/FileStorageClientService.java
new file mode 100644
index 0000000..3e11e3e
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/storage/FileStorageClientService.java
@@ -0,0 +1,61 @@
+package dev.vality.ccreporter.storage;
+
+import dev.vality.ccreporter.config.properties.FileStorageProperties;
+import dev.vality.file.storage.FileStorageSrv;
+import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+
+@Component
+@RequiredArgsConstructor
+public class FileStorageClientService implements FileStorageService {
+
+ private final FileStorageSrv.Iface fileStorageClient;
+ private final HttpClient httpClient;
+ private final FileStorageProperties fileStorageProperties;
+
+ @Override
+ @SneakyThrows
+ public String storeFile(String fileName, String contentType, Path contentPath, Instant expiresAt) {
+ return uploadFile(contentType, HttpRequest.BodyPublishers.ofFile(contentPath), expiresAt);
+ }
+
+ @Override
+ @SneakyThrows
+ public String generateDownloadUrl(String fileId, Instant expiresAt) {
+ return fileStorageClient.generateDownloadUrl(fileId, expiresAt.toString());
+ }
+
+ @SneakyThrows
+ private String uploadFile(String contentType, HttpRequest.BodyPublisher bodyPublisher, Instant expiresAt) {
+ var newFileResult = fileStorageClient.createNewFile(
+ Collections.emptyMap(),
+ expiresAt.toString()
+ );
+ var request = HttpRequest.newBuilder()
+ .uri(URI.create(newFileResult.getUploadUrl()))
+ .header("Content-Type", contentType)
+ .timeout(Duration.ofMillis(fileStorageProperties.getNetworkTimeout()))
+ .PUT(bodyPublisher)
+ .build();
+ var response = httpClient.send(
+ request,
+ HttpResponse.BodyHandlers.discarding()
+ );
+ if (response.statusCode() / 100 != 2) {
+ throw new IllegalStateException(
+ "File upload failed with status: " + response.statusCode()
+ );
+ }
+ return newFileResult.getFileDataId();
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/storage/FileStorageService.java b/src/main/java/dev/vality/ccreporter/storage/FileStorageService.java
new file mode 100644
index 0000000..9af2e5f
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/storage/FileStorageService.java
@@ -0,0 +1,11 @@
+package dev.vality.ccreporter.storage;
+
+import java.nio.file.Path;
+import java.time.Instant;
+
+public interface FileStorageService {
+
+ String storeFile(String fileName, String contentType, Path contentPath, Instant expiresAt);
+
+ String generateDownloadUrl(String fileId, Instant expiresAt);
+}
diff --git a/src/main/java/dev/vality/ccreporter/util/SearchValueNormalizer.java b/src/main/java/dev/vality/ccreporter/util/SearchValueNormalizer.java
new file mode 100644
index 0000000..91622e9
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/util/SearchValueNormalizer.java
@@ -0,0 +1,18 @@
+package dev.vality.ccreporter.util;
+
+import lombok.experimental.UtilityClass;
+
+import java.util.Locale;
+import java.util.stream.Stream;
+
+@UtilityClass
+public class SearchValueNormalizer {
+
+ public static String normalize(String... values) {
+ return Stream.of(values)
+ .filter(value -> value != null && !value.isBlank())
+ .map(value -> value.toLowerCase(Locale.ROOT))
+ .reduce((left, right) -> left + " " + right)
+ .orElse(null);
+ }
+}
diff --git a/src/main/java/dev/vality/ccreporter/util/TimestampUtils.java b/src/main/java/dev/vality/ccreporter/util/TimestampUtils.java
new file mode 100644
index 0000000..13c1e06
--- /dev/null
+++ b/src/main/java/dev/vality/ccreporter/util/TimestampUtils.java
@@ -0,0 +1,38 @@
+package dev.vality.ccreporter.util;
+
+import lombok.experimental.UtilityClass;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+
+@UtilityClass
+public class TimestampUtils {
+
+ private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_INSTANT;
+
+ public static Instant parse(String value) {
+ return Instant.parse(value);
+ }
+
+ public static String format(Instant value) {
+ return FORMATTER.format(value);
+ }
+
+ public static LocalDateTime toLocalDateTime(Instant value) {
+ return LocalDateTime.ofInstant(value, ZoneOffset.UTC);
+ }
+
+ public static LocalDateTime toLocalDateTime(String value) {
+ return toLocalDateTime(Instant.parse(value));
+ }
+
+ public static LocalDateTime toNullableLocalDateTime(Instant value) {
+ return value == null ? null : LocalDateTime.ofInstant(value, ZoneOffset.UTC);
+ }
+
+ public static Instant toInstant(LocalDateTime value) {
+ return value.toInstant(ZoneOffset.UTC);
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..18915e6
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,112 @@
+server:
+ port: ${server.port}
+
+spring:
+ application:
+ name: ${project.name}
+ datasource:
+ type: com.zaxxer.hikari.HikariDataSource
+ driver-class-name: org.postgresql.Driver
+ url: ${db.url}
+ username: ${db.user}
+ password: ${db.password}
+ hikari:
+ connection-timeout: 5000
+ validation-timeout: 3000
+ idle-timeout: 30000
+ minimum-idle: 2
+ maximum-pool-size: 8
+ data-source-properties:
+ reWriteBatchedInserts: true
+ connectTimeout: 5
+ socketTimeout: 1260
+ cancelSignalTimeout: 5
+ tcpKeepAlive: true
+ flyway:
+ enabled: true
+ schemas: ${db.schema}
+ postgresql:
+ transactional-lock: false
+ kafka:
+ bootstrap-servers: localhost:9092
+ client-id: cc-reporter
+ consumer:
+ group-id: cc-reporter
+ enable-auto-commit: false
+ auto-offset-reset: earliest
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ max-poll-records: 20
+ properties:
+ max.poll.interval.ms: 30000
+ session.timeout.ms: 30000
+ listener:
+ ack-mode: manual
+ poll-timeout: 3000ms
+
+management:
+ server:
+ port: ${management.port}
+ prometheus:
+ metrics:
+ export:
+ enabled: false
+ metrics:
+ tags:
+ application: ${project.name}
+ endpoint:
+ health:
+ access: none
+ probes:
+ enabled: false
+ show-details: never
+ metrics:
+ access: none
+ prometheus:
+ access: none
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics,prometheus
+ discovery:
+ enabled: false
+
+kafka:
+ consumer:
+ dominant-concurrency: 1
+ payments-concurrency: 1
+ withdrawals-concurrency: 1
+ withdrawal-sessions-concurrency: 1
+ error-backoff-interval-ms: 30000
+ error-max-attempts: -1
+ dominant-error-backoff-interval-ms: 30000
+ dominant-error-max-attempts: -1
+ topics:
+ dominant:
+ id: ""
+ enabled: false
+ payments:
+ id: ""
+ enabled: false
+ withdrawals:
+ id: ""
+ enabled: false
+ withdrawal-sessions:
+ id: ""
+ enabled: false
+scheduler:
+ enabled: false
+ poll-interval-ms: 10000
+report:
+ max-attempts: 5
+ worker-concurrency: 2
+ processing-timeout-ms: 1200000
+ presigned-url-ttl-sec: 900
+ expiration-sec: 604800
+api:
+ path: /ccreports
+ default-page-size: 50
+ max-page-size: 100
+storage:
+ file-storage:
+ url: ""
+ networkTimeout: 5000
diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql
new file mode 100644
index 0000000..71f9faa
--- /dev/null
+++ b/src/main/resources/db/migration/V1__init.sql
@@ -0,0 +1,279 @@
+CREATE SCHEMA IF NOT EXISTS ccr;
+
+-- Needed for case-insensitive partial search by name/id fragments.
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+CREATE TYPE ccr.report_status AS ENUM (
+ 'pending',
+ 'processing',
+ 'created',
+ 'failed',
+ 'canceled',
+ 'timed_out',
+ 'expired'
+);
+
+CREATE TYPE ccr.report_type AS ENUM (
+ 'payments',
+ 'withdrawals'
+);
+
+CREATE TYPE ccr.file_type AS ENUM (
+ 'csv'
+);
+
+CREATE TABLE ccr.report_job (
+ id BIGSERIAL PRIMARY KEY,
+
+ report_type ccr.report_type NOT NULL,
+ file_type ccr.file_type NOT NULL,
+ query_json JSONB NOT NULL,
+ timezone VARCHAR NOT NULL DEFAULT 'UTC',
+
+ status ccr.report_status NOT NULL DEFAULT 'pending',
+ created_by VARCHAR NOT NULL,
+ idempotency_key VARCHAR,
+
+ rows_count BIGINT,
+ attempt INT NOT NULL DEFAULT 0,
+ next_attempt_at TIMESTAMP WITHOUT TIME ZONE,
+ data_snapshot_fixed_at TIMESTAMP WITHOUT TIME ZONE,
+
+ error_code VARCHAR,
+ error_message VARCHAR,
+
+ created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc'),
+ started_at TIMESTAMP WITHOUT TIME ZONE,
+ finished_at TIMESTAMP WITHOUT TIME ZONE,
+ expires_at TIMESTAMP WITHOUT TIME ZONE,
+
+ CONSTRAINT report_job_attempt_chk CHECK (attempt >= 0)
+);
+
+CREATE TABLE ccr.report_file (
+ id BIGSERIAL PRIMARY KEY,
+ report_id BIGINT NOT NULL REFERENCES ccr.report_job(id) ON DELETE CASCADE,
+
+ file_id VARCHAR NOT NULL UNIQUE,
+ file_type ccr.file_type NOT NULL,
+ filename VARCHAR NOT NULL,
+ content_type VARCHAR NOT NULL DEFAULT 'text/csv',
+
+ size_bytes BIGINT,
+ md5 VARCHAR NOT NULL,
+ sha256 VARCHAR NOT NULL,
+
+ created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc'),
+
+ CONSTRAINT report_file_report_id_uniq UNIQUE (report_id)
+);
+
+CREATE TABLE ccr.report_audit_event (
+ id BIGSERIAL PRIMARY KEY,
+ report_id BIGINT NOT NULL REFERENCES ccr.report_job(id) ON DELETE CASCADE,
+ event_type VARCHAR NOT NULL,
+ actor VARCHAR,
+ payload_json JSONB,
+ created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc')
+);
+
+CREATE TABLE ccr.shop_lookup (
+ shop_id VARCHAR PRIMARY KEY,
+ shop_name VARCHAR,
+ shop_search VARCHAR,
+ dominant_version_id BIGINT NOT NULL DEFAULT 0,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc')
+);
+
+CREATE TABLE ccr.provider_lookup (
+ provider_id VARCHAR PRIMARY KEY,
+ provider_name VARCHAR,
+ provider_search VARCHAR,
+ dominant_version_id BIGINT NOT NULL DEFAULT 0,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc')
+);
+
+CREATE TABLE ccr.terminal_lookup (
+ terminal_id VARCHAR PRIMARY KEY,
+ terminal_name VARCHAR,
+ terminal_search VARCHAR,
+ dominant_version_id BIGINT NOT NULL DEFAULT 0,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc')
+);
+
+CREATE TABLE ccr.wallet_lookup (
+ wallet_id VARCHAR PRIMARY KEY,
+ wallet_name VARCHAR,
+ wallet_search VARCHAR,
+ dominant_version_id BIGINT NOT NULL DEFAULT 0,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc')
+);
+
+CREATE TABLE ccr.payment_txn_current (
+ id BIGSERIAL PRIMARY KEY,
+ invoice_id VARCHAR NOT NULL,
+ payment_id VARCHAR NOT NULL,
+ domain_event_id BIGINT NOT NULL,
+ domain_event_created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ party_id VARCHAR,
+ shop_id VARCHAR,
+ created_at TIMESTAMP WITHOUT TIME ZONE,
+ finalized_at TIMESTAMP WITHOUT TIME ZONE,
+ status VARCHAR,
+ provider_id VARCHAR,
+ terminal_id VARCHAR,
+ amount BIGINT,
+ fee BIGINT,
+ currency VARCHAR,
+ trx_id VARCHAR,
+ external_id VARCHAR,
+ rrn VARCHAR,
+ approval_code VARCHAR,
+ payment_tool_type VARCHAR,
+ error_summary VARCHAR,
+ original_amount BIGINT,
+ original_currency VARCHAR,
+ converted_amount BIGINT,
+ exchange_rate_internal NUMERIC(20, 10),
+ provider_amount BIGINT,
+ provider_currency VARCHAR,
+ trx_search VARCHAR,
+ updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc'),
+
+ CONSTRAINT payment_txn_current_event_chk CHECK (domain_event_id > 0),
+ CONSTRAINT payment_txn_current_uniq UNIQUE (invoice_id, payment_id)
+);
+
+CREATE TABLE ccr.withdrawal_txn_current (
+ withdrawal_id VARCHAR PRIMARY KEY,
+ domain_event_id BIGINT NOT NULL,
+ domain_event_created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ party_id VARCHAR,
+ wallet_id VARCHAR,
+ destination_id VARCHAR,
+ created_at TIMESTAMP WITHOUT TIME ZONE,
+ finalized_at TIMESTAMP WITHOUT TIME ZONE,
+ status VARCHAR,
+ provider_id VARCHAR,
+ terminal_id VARCHAR,
+ amount BIGINT,
+ fee BIGINT,
+ currency VARCHAR,
+ external_id VARCHAR,
+ error_summary VARCHAR,
+ original_amount BIGINT,
+ original_currency VARCHAR,
+ converted_amount BIGINT,
+ exchange_rate_internal NUMERIC(20, 10),
+ provider_amount BIGINT,
+ provider_currency VARCHAR,
+ updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc'),
+
+ CONSTRAINT withdrawal_txn_current_event_chk CHECK (domain_event_id > 0)
+);
+
+CREATE TABLE ccr.withdrawal_session (
+ session_id VARCHAR PRIMARY KEY,
+ domain_event_id BIGINT NOT NULL,
+ domain_event_created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ withdrawal_id VARCHAR,
+ trx_id VARCHAR,
+ trx_search VARCHAR,
+ updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (now() AT TIME ZONE 'utc'),
+
+ CONSTRAINT withdrawal_session_event_chk CHECK (domain_event_id > 0)
+);
+
+CREATE INDEX report_job_created_by_created_at_idx
+ ON ccr.report_job (created_by, created_at DESC, id DESC);
+
+CREATE INDEX report_job_pending_idx
+ ON ccr.report_job (next_attempt_at, created_at, id)
+ WHERE status = 'pending';
+
+CREATE INDEX report_job_processing_started_at_idx
+ ON ccr.report_job (started_at, id)
+ WHERE status = 'processing';
+
+CREATE INDEX report_job_report_type_file_type_created_at_idx
+ ON ccr.report_job (report_type, file_type, created_at DESC, id DESC);
+
+CREATE INDEX report_job_expires_at_idx
+ ON ccr.report_job (expires_at)
+ WHERE expires_at IS NOT NULL;
+
+CREATE UNIQUE INDEX report_job_idempotency_key_uniq
+ ON ccr.report_job (created_by, idempotency_key)
+ WHERE idempotency_key IS NOT NULL;
+
+CREATE INDEX report_audit_event_report_id_idx
+ ON ccr.report_audit_event (report_id, created_at DESC, id DESC);
+
+CREATE INDEX shop_lookup_search_trgm_idx
+ ON ccr.shop_lookup USING gin (shop_search gin_trgm_ops);
+
+CREATE INDEX provider_lookup_search_trgm_idx
+ ON ccr.provider_lookup USING gin (provider_search gin_trgm_ops);
+
+CREATE INDEX terminal_lookup_search_trgm_idx
+ ON ccr.terminal_lookup USING gin (terminal_search gin_trgm_ops);
+
+CREATE INDEX wallet_lookup_search_trgm_idx
+ ON ccr.wallet_lookup USING gin (wallet_search gin_trgm_ops);
+
+CREATE INDEX payment_txn_created_at_idx
+ ON ccr.payment_txn_current (created_at DESC, id DESC);
+
+CREATE INDEX payment_txn_finalized_at_idx
+ ON ccr.payment_txn_current (finalized_at DESC, id DESC)
+ WHERE finalized_at IS NOT NULL;
+
+CREATE INDEX payment_txn_filters_idx
+ ON ccr.payment_txn_current (
+ party_id,
+ shop_id,
+ provider_id,
+ terminal_id,
+ status,
+ currency,
+ created_at DESC,
+ id DESC
+ );
+
+CREATE INDEX payment_txn_trx_idx
+ ON ccr.payment_txn_current (trx_id);
+
+CREATE INDEX payment_txn_trx_trgm_idx
+ ON ccr.payment_txn_current USING gin (trx_search gin_trgm_ops);
+
+CREATE INDEX withdrawal_txn_created_at_idx
+ ON ccr.withdrawal_txn_current (created_at DESC, withdrawal_id);
+
+CREATE INDEX withdrawal_txn_finalized_at_idx
+ ON ccr.withdrawal_txn_current (finalized_at DESC, withdrawal_id)
+ WHERE finalized_at IS NOT NULL;
+
+CREATE INDEX withdrawal_txn_filters_idx
+ ON ccr.withdrawal_txn_current (
+ party_id,
+ wallet_id,
+ provider_id,
+ terminal_id,
+ status,
+ currency,
+ created_at DESC,
+ withdrawal_id
+ );
+
+CREATE INDEX withdrawal_session_trx_idx
+ ON ccr.withdrawal_session (trx_id);
+
+CREATE INDEX withdrawal_session_trx_trgm_idx
+ ON ccr.withdrawal_session USING gin (trx_search gin_trgm_ops);
+
+CREATE INDEX withdrawal_session_withdrawal_idx
+ ON ccr.withdrawal_session (withdrawal_id);
diff --git a/src/test/java/dev/vality/ccreporter/config/FileStorageConfigTest.java b/src/test/java/dev/vality/ccreporter/config/FileStorageConfigTest.java
new file mode 100644
index 0000000..1753435
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/config/FileStorageConfigTest.java
@@ -0,0 +1,21 @@
+package dev.vality.ccreporter.config;
+
+import dev.vality.ccreporter.config.properties.FileStorageProperties;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class FileStorageConfigTest {
+
+ @Test
+ void httpClientUsesConfiguredNetworkTimeout() {
+ var properties = new FileStorageProperties();
+ properties.setNetworkTimeout(1_234);
+
+ var httpClient = new FileStorageConfig().httpClient(properties);
+
+ assertThat(httpClient.connectTimeout()).contains(Duration.ofMillis(1_234));
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/config/ReportWorkerConfigTest.java b/src/test/java/dev/vality/ccreporter/config/ReportWorkerConfigTest.java
new file mode 100644
index 0000000..bc9666d
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/config/ReportWorkerConfigTest.java
@@ -0,0 +1,40 @@
+package dev.vality.ccreporter.config;
+
+import com.zaxxer.hikari.HikariDataSource;
+import dev.vality.ccreporter.config.properties.ReportProperties;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ReportWorkerConfigTest {
+
+ @Test
+ void rejectsPoolWithoutConnectionReservedForLifecycleTransitions() {
+ var reportProperties = new ReportProperties();
+ reportProperties.setWorkerConcurrency(2);
+ try (var dataSource = new HikariDataSource()) {
+ dataSource.setMaximumPoolSize(3);
+
+ assertThatThrownBy(() -> new ReportWorkerConfig().reportWorkerExecutor(reportProperties, dataSource))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("maximum-pool-size must be at least 4");
+ }
+ }
+
+ @Test
+ void acceptsPoolWithMinimumConnectionReserve() {
+ var reportProperties = new ReportProperties();
+ reportProperties.setWorkerConcurrency(2);
+ try (var dataSource = new HikariDataSource()) {
+ dataSource.setMaximumPoolSize(4);
+
+ var executor = new ReportWorkerConfig().reportWorkerExecutor(reportProperties, dataSource);
+ try {
+ assertThat(executor.isShutdown()).isFalse();
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/CurrentStateTableFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/CurrentStateTableFixtures.java
new file mode 100644
index 0000000..76e32e3
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/CurrentStateTableFixtures.java
@@ -0,0 +1,152 @@
+package dev.vality.ccreporter.fixture;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+/**
+ * Наполняет current-state таблицы тестовыми строками там, где нужен готовый срез данных без участия ingestion.
+ */
+public final class CurrentStateTableFixtures {
+
+ private CurrentStateTableFixtures() {
+ }
+
+ public static void insertPaymentRow(
+ JdbcTemplate jdbcTemplate,
+ String invoiceId,
+ String paymentId,
+ Instant createdAt,
+ Instant finalizedAt
+ ) {
+ jdbcTemplate.update(
+ """
+ INSERT INTO ccr.payment_txn_current (
+ invoice_id, payment_id, domain_event_id, domain_event_created_at,
+ party_id, shop_id, created_at, finalized_at, status,
+ provider_id, terminal_id, amount,
+ fee, currency, trx_id, external_id, rrn, approval_code,
+ payment_tool_type, error_summary, original_amount, original_currency,
+ converted_amount, exchange_rate_internal, provider_amount, provider_currency,
+ trx_search
+ )
+ VALUES (
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
+ )
+ """,
+ invoiceId,
+ paymentId,
+ 1L,
+ toUtcLocalDateTime(createdAt),
+ "party-1",
+ "shop-1",
+ toUtcLocalDateTime(createdAt),
+ finalizedAt == null ? null : toUtcLocalDateTime(finalizedAt),
+ "captured",
+ "provider-1",
+ "terminal-1",
+ 1000L,
+ 10L,
+ "RUB",
+ "trx-1",
+ "external-1",
+ "rrn-1",
+ "approval-1",
+ "bank_card",
+ null,
+ 1100L,
+ "USD",
+ 1000L,
+ new BigDecimal("1.1000000000"),
+ 990L,
+ "EUR",
+ "trx-1"
+ );
+ }
+
+ public static void insertWithdrawalRow(
+ JdbcTemplate jdbcTemplate,
+ String withdrawalId,
+ Instant createdAt,
+ Instant finalizedAt
+ ) {
+ jdbcTemplate.update(
+ """
+ INSERT INTO ccr.withdrawal_txn_current (
+ withdrawal_id, domain_event_id, domain_event_created_at,
+ party_id, wallet_id, destination_id, created_at,
+ finalized_at, status, provider_id, terminal_id, amount, fee, currency, external_id,
+ error_summary, original_amount, original_currency, exchange_rate_internal,
+ provider_amount, provider_currency, converted_amount
+ )
+ VALUES (
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
+ ?, ?, ?, ?, ?, ?
+ )
+ """,
+ withdrawalId,
+ 1L,
+ toUtcLocalDateTime(createdAt),
+ "party-1",
+ "wallet-1",
+ "destination-1",
+ toUtcLocalDateTime(createdAt),
+ finalizedAt == null ? null : toUtcLocalDateTime(finalizedAt),
+ "succeeded",
+ "provider-1",
+ "terminal-1",
+ 2000L,
+ 20L,
+ "RUB",
+ "external-w-1",
+ null,
+ 2100L,
+ "USD",
+ new BigDecimal("1.0500000000"),
+ 1990L,
+ "EUR",
+ 2000L
+ );
+ insertWithdrawalSessionRow(
+ jdbcTemplate,
+ "session-" + withdrawalId,
+ withdrawalId,
+ 1L,
+ createdAt,
+ "trx-w-1"
+ );
+ }
+
+ public static void insertWithdrawalSessionRow(
+ JdbcTemplate jdbcTemplate,
+ String sessionId,
+ String withdrawalId,
+ long domainEventId,
+ Instant eventCreatedAt,
+ String trxId
+ ) {
+ jdbcTemplate.update(
+ """
+ INSERT INTO ccr.withdrawal_session (
+ session_id, withdrawal_id, domain_event_id, domain_event_created_at,
+ trx_id, trx_search
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ sessionId,
+ withdrawalId,
+ domainEventId,
+ toUtcLocalDateTime(eventCreatedAt),
+ trxId,
+ trxId
+ );
+ }
+
+ private static LocalDateTime toUtcLocalDateTime(Instant value) {
+ return LocalDateTime.ofInstant(value, ZoneOffset.UTC);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/CurrentStateUpdateFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/CurrentStateUpdateFixtures.java
new file mode 100644
index 0000000..c09e3d1
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/CurrentStateUpdateFixtures.java
@@ -0,0 +1,105 @@
+package dev.vality.ccreporter.fixture;
+
+import dev.vality.ccreporter.domain.tables.pojos.PaymentTxnCurrent;
+import dev.vality.ccreporter.domain.tables.pojos.WithdrawalSession;
+import dev.vality.ccreporter.domain.tables.pojos.WithdrawalTxnCurrent;
+import dev.vality.ccreporter.util.TimestampUtils;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalDateTime;
+
+import static dev.vality.ccreporter.util.SearchValueNormalizer.normalize;
+
+/**
+ * Собирает доменные апдейты для DAO-тестов, чтобы сценарии не расползались из-за ручной сборки всех полей.
+ */
+public final class CurrentStateUpdateFixtures {
+
+ private CurrentStateUpdateFixtures() {
+ }
+
+ public static PaymentTxnCurrent paymentUpdate(long eventId, String status, Instant finalizedAt) {
+ return new PaymentTxnCurrent()
+ .setInvoiceId("invoice-1")
+ .setPaymentId("payment-1")
+ .setDomainEventId(eventId)
+ .setDomainEventCreatedAt(TimestampUtils.toLocalDateTime(
+ Instant.parse("2026-01-01T10:00:00Z").plusSeconds(eventId)
+ ))
+ .setPartyId("party-1")
+ .setShopId("shop-1")
+ .setCreatedAt(toLocalDateTime(Instant.parse("2026-01-01T10:00:00Z")))
+ .setFinalizedAt(toLocalDateTime(finalizedAt))
+ .setStatus(status)
+ .setProviderId("provider-1")
+ .setTerminalId("terminal-1")
+ .setAmount(1000L)
+ .setFee(10L)
+ .setCurrency("RUB")
+ .setTrxId("trx-1")
+ .setExternalId("external-1")
+ .setRrn("rrn-1")
+ .setApprovalCode("approval-1")
+ .setPaymentToolType("bank_card")
+ .setOriginalAmount(1000L)
+ .setOriginalCurrency("RUB")
+ .setConvertedAmount(1000L)
+ .setExchangeRateInternal(BigDecimal.ONE)
+ .setProviderAmount(1000L)
+ .setProviderCurrency("RUB");
+ }
+
+ public static WithdrawalTxnCurrent withdrawalUpdate(
+ long eventId,
+ String status,
+ Instant finalizedAt
+ ) {
+ return new WithdrawalTxnCurrent()
+ .setWithdrawalId("withdrawal-1")
+ .setDomainEventId(eventId)
+ .setDomainEventCreatedAt(TimestampUtils.toLocalDateTime(
+ Instant.parse("2026-01-01T11:00:00Z").plusSeconds(eventId)
+ ))
+ .setPartyId("party-1")
+ .setWalletId("wallet-1")
+ .setDestinationId("destination-1")
+ .setCreatedAt(toLocalDateTime(Instant.parse("2026-01-01T11:00:00Z")))
+ .setFinalizedAt(toLocalDateTime(finalizedAt))
+ .setStatus(status)
+ .setProviderId("provider-1")
+ .setTerminalId("terminal-1")
+ .setAmount(2000L)
+ .setFee(20L)
+ .setCurrency("RUB")
+ .setExternalId("external-1")
+ .setOriginalAmount(2100L)
+ .setOriginalCurrency("USD")
+ .setConvertedAmount(2000L)
+ .setExchangeRateInternal(new BigDecimal("0.9523809524"))
+ .setProviderAmount(2000L)
+ .setProviderCurrency("RUB");
+ }
+
+ public static WithdrawalSession withdrawalSessionUpdate(
+ String sessionId,
+ String withdrawalId,
+ long eventId,
+ Instant eventCreatedAt,
+ String trxId
+ ) {
+ var session = new WithdrawalSession()
+ .setSessionId(sessionId)
+ .setWithdrawalId(withdrawalId)
+ .setDomainEventId(eventId)
+ .setDomainEventCreatedAt(toLocalDateTime(eventCreatedAt));
+ if (trxId != null) {
+ session.setTrxId(trxId).setTrxSearch(normalize(trxId));
+ }
+ return session;
+ }
+
+ private static LocalDateTime toLocalDateTime(Instant value) {
+ return value == null ? null : TimestampUtils.toLocalDateTime(value);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/DominantCommitFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/DominantCommitFixtures.java
new file mode 100644
index 0000000..a8d5c54
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/DominantCommitFixtures.java
@@ -0,0 +1,80 @@
+package dev.vality.ccreporter.fixture;
+
+import dev.vality.damsel.domain.*;
+import dev.vality.damsel.domain_config_v2.*;
+
+import java.util.List;
+
+public final class DominantCommitFixtures {
+
+ private DominantCommitFixtures() {
+ }
+
+ public static HistoricalCommit insertCommit(long version) {
+ return commit(
+ version,
+ List.of(
+ FinalOperation.insert(new FinalInsertOp(shopObject("shop-lookup", "Lookup Shop"))),
+ FinalOperation.insert(new FinalInsertOp(providerObject(1001, "Lookup Provider"))),
+ FinalOperation.insert(new FinalInsertOp(terminalObject(1002, "Lookup Terminal"))),
+ FinalOperation.insert(new FinalInsertOp(walletObject("wallet-lookup", "Lookup Wallet")))
+ )
+ );
+ }
+
+ public static HistoricalCommit updateShopCommit(long version, String name) {
+ return commit(version, List.of(FinalOperation.update(new UpdateOp(shopObject("shop-lookup", name)))));
+ }
+
+ public static HistoricalCommit removeShopCommit(long version) {
+ return commit(version, List.of(FinalOperation.remove(new RemoveOp(shopReference("shop-lookup")))));
+ }
+
+ public static HistoricalCommit updateProviderCommit(long version, String name) {
+ return commit(version, List.of(FinalOperation.update(new UpdateOp(providerObject(1001, name)))));
+ }
+
+ private static HistoricalCommit commit(long version, List operations) {
+ return new HistoricalCommit()
+ .setVersion(version)
+ .setCreatedAt("2026-01-01T00:00:00")
+ .setChangedBy(new Author().setId("dominant").setName("dominant").setEmail("dominant@test"))
+ .setOps(operations);
+ }
+
+ private static DomainObject shopObject(String shopId, String name) {
+ return DomainObject.shop_config(
+ new ShopConfigObject()
+ .setRef(new ShopConfigRef().setId(shopId))
+ .setData(new ShopConfig().setName(name))
+ );
+ }
+
+ private static DomainObject providerObject(int providerId, String name) {
+ return DomainObject.provider(
+ new ProviderObject()
+ .setRef(new ProviderRef().setId(providerId))
+ .setData(new Provider().setName(name))
+ );
+ }
+
+ private static DomainObject terminalObject(int terminalId, String name) {
+ return DomainObject.terminal(
+ new TerminalObject()
+ .setRef(new TerminalRef().setId(terminalId))
+ .setData(new Terminal().setName(name))
+ );
+ }
+
+ private static DomainObject walletObject(String walletId, String name) {
+ return DomainObject.wallet_config(
+ new WalletConfigObject()
+ .setRef(new WalletConfigRef().setId(walletId))
+ .setData(new WalletConfig().setName(name))
+ );
+ }
+
+ private static Reference shopReference(String shopId) {
+ return Reference.shop_config(new ShopConfigRef().setId(shopId));
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/PaymentIngestionEventFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/PaymentIngestionEventFixtures.java
new file mode 100644
index 0000000..11e651d
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/PaymentIngestionEventFixtures.java
@@ -0,0 +1,357 @@
+package dev.vality.ccreporter.fixture;
+
+import dev.vality.ccreporter.serde.thrift.ThriftSerializer;
+import dev.vality.damsel.domain.*;
+import dev.vality.damsel.domain.InvoicePayment;
+import dev.vality.damsel.domain.InvoicePaymentPending;
+import dev.vality.damsel.payment_processing.*;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import dev.vality.machinegun.msgpack.Value;
+import org.apache.thrift.TBase;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Собирает payment events в том же виде, в котором ingestion получает их из event sink.
+ */
+public final class PaymentIngestionEventFixtures {
+
+ public static final String PAYMENT_INVOICE_ID = "invoice-serialized";
+ public static final String PAYMENT_ID = "payment-serialized";
+
+ private static final ThriftSerializer> THRIFT_SERIALIZER = new ThriftSerializer<>();
+
+ private PaymentIngestionEventFixtures() {
+ }
+
+ public static List paymentEvents() {
+ return List.of(
+ paymentMachineEvent(1L, startedPayload()),
+ paymentMachineEvent(2L, cashFlowChangedPayload()),
+ paymentMachineEvent(3L, transactionBoundPayload()),
+ paymentMachineEvent(4L, statusChangedPayload())
+ );
+ }
+
+ public static List paymentProxyStateFallbackEvents() {
+ return List.of(
+ paymentMachineEvent(1L, startedPayload()),
+ paymentMachineEvent(2L, proxyStatePayload()),
+ paymentMachineEvent(3L, statusChangedPayload())
+ );
+ }
+
+ public static List failedPaymentEvents() {
+ return List.of(
+ paymentMachineEvent(1L, startedPayload()),
+ paymentMachineEvent(2L, failedStatusChangedPayload())
+ );
+ }
+
+ public static List paymentChangesCombinedInSingleEvent() {
+ var changes = new ArrayList();
+ changes.addAll(startedPayload().getInvoiceChanges());
+ changes.addAll(cashFlowChangedPayload().getInvoiceChanges());
+ changes.addAll(transactionBoundPayload().getInvoiceChanges());
+ changes.addAll(statusChangedPayload().getInvoiceChanges());
+ var payload = new EventPayload();
+ payload.setInvoiceChanges(changes);
+ return List.of(paymentMachineEvent(1L, payload));
+ }
+
+ private static MachineEvent paymentMachineEvent(long eventId, EventPayload payload) {
+ return new MachineEvent()
+ .setEventId(eventId)
+ .setSourceId(PAYMENT_INVOICE_ID)
+ .setSourceNs("payments")
+ .setCreatedAt("2026-01-01T00:0" + eventId + ":00Z")
+ .setData(Value.bin(serialize(payload)));
+ }
+
+ private static byte[] serialize(TBase, ?> payload) {
+ return THRIFT_SERIALIZER.serialize("", payload);
+ }
+
+ private static EventPayload startedPayload() {
+ var partyRef = new PartyConfigRef();
+ partyRef.setId("party-serialized");
+
+ var shopRef = new ShopConfigRef();
+ shopRef.setId("shop-serialized");
+
+ var currency = new CurrencyRef();
+ currency.setSymbolicCode("RUB");
+
+ var cost = new Cash();
+ cost.setAmount(1000L);
+ cost.setCurrency(currency);
+
+ var status = new InvoicePaymentStatus();
+ status.setPending(new InvoicePaymentPending());
+
+ var payment = new InvoicePayment();
+ payment.setId(PAYMENT_ID);
+ payment.setPartyRef(partyRef);
+ payment.setShopRef(shopRef);
+ payment.setCreatedAt("2026-01-01T00:00:00Z");
+ payment.setExternalId("external-payment-1");
+ payment.setStatus(status);
+ payment.setCost(cost);
+ payment.setDomainRevision(1L);
+ payment.setFlow(invoicePaymentFlow());
+ payment.setPayer(invoicePaymentPayer());
+
+ var providerRef = new ProviderRef();
+ providerRef.setId(100);
+
+ var terminalRef = new TerminalRef();
+ terminalRef.setId(200);
+
+ var route = new PaymentRoute();
+ route.setProvider(providerRef);
+ route.setTerminal(terminalRef);
+
+ var started = new InvoicePaymentStarted();
+ started.setPayment(payment);
+ started.setRoute(route);
+
+ var changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentStarted(started);
+
+ var paymentChange = new InvoicePaymentChange();
+ paymentChange.setId(PAYMENT_ID);
+ paymentChange.setPayload(changePayload);
+
+ var invoiceChange = new InvoiceChange();
+ invoiceChange.setInvoicePaymentChange(paymentChange);
+
+ var eventPayload = new EventPayload();
+ eventPayload.setInvoiceChanges(List.of(invoiceChange));
+ return eventPayload;
+ }
+
+ private static EventPayload cashFlowChangedPayload() {
+ var providerAccountType = new CashFlowAccount();
+ providerAccountType.setProvider(ProviderCashFlowAccount.settlement);
+ var providerAccount = new FinalCashFlowAccount();
+ providerAccount.setAccountType(providerAccountType);
+
+ var merchantAccountType = new CashFlowAccount();
+ merchantAccountType.setMerchant(MerchantCashFlowAccount.settlement);
+ var merchantAccount = new FinalCashFlowAccount();
+ merchantAccount.setAccountType(merchantAccountType);
+
+ var rub = new CurrencyRef();
+ rub.setSymbolicCode("RUB");
+
+ var amountCash = new Cash();
+ amountCash.setAmount(1000L);
+ amountCash.setCurrency(rub);
+
+ var amountPosting = new FinalCashFlowPosting();
+ amountPosting.setSource(providerAccount);
+ amountPosting.setDestination(merchantAccount);
+ amountPosting.setVolume(amountCash);
+
+ var systemAccountType = new CashFlowAccount();
+ systemAccountType.setSystem(SystemCashFlowAccount.settlement);
+ var systemAccount = new FinalCashFlowAccount();
+ systemAccount.setAccountType(systemAccountType);
+
+ var feeCash = new Cash();
+ feeCash.setAmount(10L);
+ feeCash.setCurrency(rub.deepCopy());
+
+ var feePosting = new FinalCashFlowPosting();
+ feePosting.setSource(merchantAccount.deepCopy());
+ feePosting.setDestination(systemAccount);
+ feePosting.setVolume(feeCash);
+
+ var cashFlowChanged = new InvoicePaymentCashFlowChanged();
+ cashFlowChanged.setCashFlow(List.of(amountPosting, feePosting));
+
+ var changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentCashFlowChanged(cashFlowChanged);
+
+ var paymentChange = new InvoicePaymentChange();
+ paymentChange.setId(PAYMENT_ID);
+ paymentChange.setPayload(changePayload);
+
+ var invoiceChange = new InvoiceChange();
+ invoiceChange.setInvoicePaymentChange(paymentChange);
+
+ var eventPayload = new EventPayload();
+ eventPayload.setInvoiceChanges(List.of(invoiceChange));
+ return eventPayload;
+ }
+
+ private static EventPayload transactionBoundPayload() {
+ var additionalInfo = new AdditionalTransactionInfo();
+ additionalInfo.setRrn("rrn-payment-1");
+ additionalInfo.setApprovalCode("approval-payment-1");
+
+ var transactionInfo = new TransactionInfo();
+ transactionInfo.setId("trx-payment-1");
+ transactionInfo.setExtra(Map.of(
+ "rub_to_eur_converted_amount", "900",
+ "rub_to_eur_rate", "0.9000000000"
+ ));
+ transactionInfo.setAdditionalInfo(additionalInfo);
+
+ var transactionBound = new SessionTransactionBound();
+ transactionBound.setTrx(transactionInfo);
+
+ var sessionChangePayload = new SessionChangePayload();
+ sessionChangePayload.setSessionTransactionBound(transactionBound);
+
+ var targetStatus = new TargetInvoicePaymentStatus();
+ targetStatus.setProcessed(new InvoicePaymentProcessed());
+
+ var sessionChange = new InvoicePaymentSessionChange();
+ sessionChange.setTarget(targetStatus);
+ sessionChange.setPayload(sessionChangePayload);
+
+ var changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentSessionChange(sessionChange);
+
+ var paymentChange = new InvoicePaymentChange();
+ paymentChange.setId(PAYMENT_ID);
+ paymentChange.setPayload(changePayload);
+
+ var invoiceChange = new InvoiceChange();
+ invoiceChange.setInvoicePaymentChange(paymentChange);
+
+ var eventPayload = new EventPayload();
+ eventPayload.setInvoiceChanges(List.of(invoiceChange));
+ return eventPayload;
+ }
+
+ private static EventPayload statusChangedPayload() {
+ var currency = new CurrencyRef();
+ currency.setSymbolicCode("EUR");
+
+ var capturedCost = new Cash();
+ capturedCost.setAmount(900L);
+ capturedCost.setCurrency(currency);
+
+ var captured = new InvoicePaymentCaptured();
+ captured.setCost(capturedCost);
+
+ var status = new InvoicePaymentStatus();
+ status.setCaptured(captured);
+
+ var statusChanged = new InvoicePaymentStatusChanged();
+ statusChanged.setStatus(status);
+
+ var changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentStatusChanged(statusChanged);
+
+ var paymentChange = new InvoicePaymentChange();
+ paymentChange.setId(PAYMENT_ID);
+ paymentChange.setPayload(changePayload);
+
+ var invoiceChange = new InvoiceChange();
+ invoiceChange.setInvoicePaymentChange(paymentChange);
+
+ var eventPayload = new EventPayload();
+ eventPayload.setInvoiceChanges(List.of(invoiceChange));
+ return eventPayload;
+ }
+
+ private static EventPayload failedStatusChangedPayload() {
+ var subFailure = new SubFailure();
+ subFailure.setCode("payment_tool_rejected");
+
+ var failure = new Failure();
+ failure.setCode("authorization_failed");
+ failure.setReason("'FAILED: REFUSED' - 'Transaction failed'");
+ failure.setSub(subFailure);
+
+ var operationFailure = new OperationFailure();
+ operationFailure.setFailure(failure);
+
+ var failed = new InvoicePaymentFailed();
+ failed.setFailure(operationFailure);
+
+ var status = new InvoicePaymentStatus();
+ status.setFailed(failed);
+
+ var statusChanged = new InvoicePaymentStatusChanged();
+ statusChanged.setStatus(status);
+
+ var changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentStatusChanged(statusChanged);
+
+ var paymentChange = new InvoicePaymentChange();
+ paymentChange.setId(PAYMENT_ID);
+ paymentChange.setPayload(changePayload);
+
+ var invoiceChange = new InvoiceChange();
+ invoiceChange.setInvoicePaymentChange(paymentChange);
+
+ var eventPayload = new EventPayload();
+ eventPayload.setInvoiceChanges(List.of(invoiceChange));
+ return eventPayload;
+ }
+
+ private static EventPayload proxyStatePayload() {
+ var proxyStateChanged = new SessionProxyStateChanged();
+ proxyStateChanged.setProxyState("""
+ {"nextStep":"CHECK_STATUS","providerTrxId":"trx-from-proxy-state-1"}
+ """.getBytes(StandardCharsets.UTF_8));
+
+ var sessionChangePayload = new SessionChangePayload();
+ sessionChangePayload.setSessionProxyStateChanged(proxyStateChanged);
+
+ var targetStatus = new TargetInvoicePaymentStatus();
+ targetStatus.setProcessed(new InvoicePaymentProcessed());
+
+ var sessionChange = new InvoicePaymentSessionChange();
+ sessionChange.setTarget(targetStatus);
+ sessionChange.setPayload(sessionChangePayload);
+
+ var changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentSessionChange(sessionChange);
+
+ var paymentChange = new InvoicePaymentChange();
+ paymentChange.setId(PAYMENT_ID);
+ paymentChange.setPayload(changePayload);
+
+ var invoiceChange = new InvoiceChange();
+ invoiceChange.setInvoicePaymentChange(paymentChange);
+
+ var eventPayload = new EventPayload();
+ eventPayload.setInvoiceChanges(List.of(invoiceChange));
+ return eventPayload;
+ }
+
+ private static InvoicePaymentFlow invoicePaymentFlow() {
+ var flow = new InvoicePaymentFlow();
+ flow.setInstant(new InvoicePaymentFlowInstant());
+ return flow;
+ }
+
+ private static Payer invoicePaymentPayer() {
+ var bankCard = new BankCard();
+ bankCard.setToken("token-payment");
+ bankCard.setBin("411111");
+ bankCard.setLastDigits("1111");
+
+ var paymentTool = new PaymentTool();
+ paymentTool.setBankCard(bankCard);
+
+ var resource = new DisposablePaymentResource();
+ resource.setPaymentTool(paymentTool);
+
+ var paymentResourcePayer = new PaymentResourcePayer();
+ paymentResourcePayer.setResource(resource);
+ paymentResourcePayer.setContactInfo(new ContactInfo());
+
+ var payer = new Payer();
+ payer.setPaymentResource(paymentResourcePayer);
+ return payer;
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/RealFixtureCoverageTest.java b/src/test/java/dev/vality/ccreporter/fixture/RealFixtureCoverageTest.java
new file mode 100644
index 0000000..169a38a
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/RealFixtureCoverageTest.java
@@ -0,0 +1,37 @@
+package dev.vality.ccreporter.fixture;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RealFixtureCoverageTest {
+
+ @Test
+ void paymentCollectionFixtureCoversAllPaymentResources() throws IOException {
+ assertThat(RealPaymentIngestionEventFixtures.collectionResourceNames().stream().sorted().toList())
+ .containsExactlyElementsOf(resourceFiles("payments"));
+ }
+
+ @Test
+ void withdrawalCollectionFixtureCoversAllWithdrawalResources() throws IOException {
+ assertThat(RealWithdrawalIngestionEventFixtures.collectionResourceNames().stream().sorted().toList())
+ .containsExactlyElementsOf(resourceFiles("withdrawals"));
+ }
+
+ private static java.util.List resourceFiles(String directory) throws IOException {
+ try (var paths = Files.walk(Path.of("src/test/resources", directory), 1)) {
+ return paths
+ .filter(Files::isRegularFile)
+ .map(path -> "payments".equals(directory)
+ ? "payments/" + path.getFileName()
+ : "withdrawals/" + path.getFileName())
+ .sorted(Comparator.naturalOrder())
+ .toList();
+ }
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/RealPaymentIngestionEventFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/RealPaymentIngestionEventFixtures.java
new file mode 100644
index 0000000..e166005
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/RealPaymentIngestionEventFixtures.java
@@ -0,0 +1,417 @@
+package dev.vality.ccreporter.fixture;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import dev.vality.ccreporter.serde.thrift.ThriftSerializer;
+import dev.vality.damsel.domain.*;
+import dev.vality.damsel.domain.InvoicePayment;
+import dev.vality.damsel.domain.InvoicePaymentPending;
+import dev.vality.damsel.payment_processing.*;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import dev.vality.machinegun.msgpack.Value;
+import org.apache.thrift.TBase;
+import org.springframework.core.io.ClassPathResource;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Загружает санитизированный production-like payment flow из test resources
+ * и превращает его в MachineEvent batch.
+ */
+public final class RealPaymentIngestionEventFixtures {
+
+ public static final String PAYMENT_INVOICE_ID = "2EnbPdxImPo";
+ public static final String PAYMENT_ID = "1";
+ public static final String LEGACY_PAYMENT_INVOICE_ID = "test-invoice-1";
+ public static final String LEGACY_PAYMENT_ID = "1";
+
+ private static final String RESOURCE_NAME = "payments/2EnbPdxImPo_events.txt";
+ private static final String LEGACY_RESOURCE_NAME = "payments/response (1).txt";
+ private static final List COLLECTION_RESOURCE_NAMES = List.of(
+ "payments/2EfF8NQk30a.txt",
+ "payments/2Ek6RLXFbyi.txt",
+ "payments/2El3kaBqBU0.txt",
+ "payments/2EloA78BbF2.txt",
+ "payments/2ElsBI5GY4m.txt",
+ "payments/2EnbPdxImPo_events.txt",
+ "payments/response (1).txt",
+ "payments/response (2).txt",
+ "payments/response (3).txt",
+ "payments/response (4).txt",
+ "payments/response (5).txt",
+ "payments/response (6).txt"
+ );
+ private static final int INITIAL_PROVIDER_ID = 254;
+ private static final int INITIAL_TERMINAL_ID = 2550;
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final ThriftSerializer> THRIFT_SERIALIZER = new ThriftSerializer<>();
+
+ private RealPaymentIngestionEventFixtures() {
+ }
+
+ public static List paymentEvents() {
+ return paymentEvents(RESOURCE_NAME);
+ }
+
+ private static List paymentEvents(String resourceName) {
+ try {
+ var root = (ArrayNode) OBJECT_MAPPER.readTree(extractJsonArray(readResource(resourceName), resourceName));
+ var machineEvents = new ArrayList(root.size());
+ for (JsonNode eventNode : root) {
+ buildPayload(eventNode).ifPresent(payload ->
+ machineEvents.add(new MachineEvent()
+ .setEventId(eventNode.path("id").asLong())
+ .setSourceId(eventNode.path("source").path("invoice_id").asText())
+ .setSourceNs("payments")
+ .setCreatedAt(eventNode.path("created_at").asText())
+ .setData(Value.bin(serialize(payload))))
+ );
+ }
+ return machineEvents;
+ } catch (IOException ex) {
+ throw new UncheckedIOException("Failed to load real payment ingestion fixture", ex);
+ }
+ }
+
+ public static List legacyPaymentEvents() {
+ return paymentEvents(LEGACY_RESOURCE_NAME);
+ }
+
+ public static List paymentCollectionEvents() {
+ return COLLECTION_RESOURCE_NAMES.stream()
+ .flatMap(resourceName -> paymentEvents(resourceName).stream())
+ .toList();
+ }
+
+ static List collectionResourceNames() {
+ return COLLECTION_RESOURCE_NAMES;
+ }
+
+ private static byte[] serialize(TBase, ?> payload) {
+ return THRIFT_SERIALIZER.serialize("", payload);
+ }
+
+ private static java.util.Optional buildPayload(JsonNode eventNode) {
+ var invoiceChangesNode = eventNode.path("payload").path("invoice_changes");
+ var invoiceChanges = new ArrayList();
+ for (JsonNode invoiceChangeNode : invoiceChangesNode) {
+ buildInvoiceChange(invoiceChangeNode).ifPresent(invoiceChanges::add);
+ }
+ if (invoiceChanges.isEmpty()) {
+ return java.util.Optional.empty();
+ }
+ var eventPayload = new EventPayload();
+ eventPayload.setInvoiceChanges(invoiceChanges);
+ return java.util.Optional.of(eventPayload);
+ }
+
+ private static java.util.Optional buildInvoiceChange(JsonNode invoiceChangeNode) {
+ var paymentChangeNode = invoiceChangeNode.get("invoice_payment_change");
+ if (paymentChangeNode == null) {
+ return java.util.Optional.empty();
+ }
+
+ var payloadNode = paymentChangeNode.path("payload");
+ InvoicePaymentChangePayload changePayload = null;
+ if (payloadNode.has("invoice_payment_started")) {
+ changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentStarted(buildStarted(payloadNode.path("invoice_payment_started")));
+ } else if (payloadNode.has("invoice_payment_route_changed")) {
+ changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentRouteChanged(buildRouteChanged(
+ payloadNode.path("invoice_payment_route_changed")
+ ));
+ } else if (payloadNode.has("invoice_payment_cash_flow_changed")) {
+ changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentCashFlowChanged(buildCashFlowChanged(
+ payloadNode.path("invoice_payment_cash_flow_changed")
+ ));
+ } else if (payloadNode.has("invoice_payment_status_changed")) {
+ changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentStatusChanged(buildStatusChanged(
+ payloadNode.path("invoice_payment_status_changed")
+ ));
+ } else if (isTransactionBoundSessionChange(payloadNode)) {
+ changePayload = new InvoicePaymentChangePayload();
+ changePayload.setInvoicePaymentSessionChange(buildTransactionBoundSessionChange(
+ payloadNode.path("invoice_payment_session_change")
+ ));
+ }
+
+ if (changePayload == null) {
+ return java.util.Optional.empty();
+ }
+
+ var paymentChange = new InvoicePaymentChange();
+ paymentChange.setId(paymentChangeNode.path("id").asText());
+ paymentChange.setPayload(changePayload);
+ var invoiceChange = new InvoiceChange();
+ invoiceChange.setInvoicePaymentChange(paymentChange);
+ return java.util.Optional.of(invoiceChange);
+ }
+
+ private static InvoicePaymentStarted buildStarted(JsonNode startedNode) {
+ var paymentNode = startedNode.path("payment");
+ var payment = new InvoicePayment();
+ payment.setId(paymentNode.path("id").asText());
+ payment.setCreatedAt(paymentNode.path("created_at").asText());
+ payment.setExternalId(paymentNode.path("external_id").asText(null));
+ payment.setStatus(buildStatus(paymentNode.path("status")));
+ payment.setCost(buildCash(paymentNode.path("cost")));
+ payment.setDomainRevision(paymentNode.path("domain_revision").asLong());
+ var partyRef = new PartyConfigRef();
+ partyRef.setId(readPartyId(paymentNode));
+ payment.setPartyRef(partyRef);
+ var shopRef = new ShopConfigRef();
+ shopRef.setId(readShopId(paymentNode));
+ payment.setShopRef(shopRef);
+ payment.setFlow(buildPaymentFlow());
+
+ if (paymentNode.has("payer")) {
+ var paymentResourcePayer = new PaymentResourcePayer();
+ paymentResourcePayer.setResource(buildDisposablePaymentResource(paymentNode.path("payer")));
+ paymentResourcePayer.setContactInfo(new ContactInfo());
+ var payer = new Payer();
+ payer.setPaymentResource(paymentResourcePayer);
+ payment.setPayer(payer);
+ }
+
+ var started = new InvoicePaymentStarted();
+ started.setPayment(payment);
+ started.setRoute(buildRoute(startedNode.path("route")));
+ return started;
+ }
+
+ private static InvoicePaymentFlow buildPaymentFlow() {
+ var flow = new InvoicePaymentFlow();
+ flow.setInstant(new InvoicePaymentFlowInstant());
+ return flow;
+ }
+
+ private static DisposablePaymentResource buildDisposablePaymentResource(JsonNode payerNode) {
+ var paymentToolRootNode = payerNode.path("payment_resource")
+ .path("resource")
+ .path("payment_tool");
+ var paymentTool = new PaymentTool();
+ if (paymentToolRootNode.has("payment_terminal")) {
+ var paymentTerminalNode = paymentToolRootNode.path("payment_terminal");
+ var paymentServiceRef = new PaymentServiceRef();
+ paymentServiceRef.setId(paymentTerminalNode.path("payment_service").path("id").asText(null));
+ var paymentTerminal = new PaymentTerminal();
+ paymentTerminal.setPaymentService(paymentServiceRef);
+ paymentTool.setPaymentTerminal(paymentTerminal);
+ } else {
+ var paymentToolNode = paymentToolRootNode.path("bank_card");
+ var bankCard = new BankCard();
+ bankCard.setToken(paymentToolNode.path("token").asText(null));
+ bankCard.setBin(paymentToolNode.path("bin").asText(null));
+ bankCard.setLastDigits(paymentToolNode.path("last_digits").asText(null));
+ paymentTool.setBankCard(bankCard);
+ }
+
+ var resource = new DisposablePaymentResource();
+ resource.setPaymentTool(paymentTool);
+ return resource;
+ }
+
+ private static InvoicePaymentRouteChanged buildRouteChanged(JsonNode routeChangedNode) {
+ var routeChanged = new InvoicePaymentRouteChanged();
+ routeChanged.setRoute(buildRoute(routeChangedNode.path("route")));
+ return routeChanged;
+ }
+
+ private static InvoicePaymentCashFlowChanged buildCashFlowChanged(JsonNode cashFlowChangedNode) {
+ var postings = new ArrayList();
+ for (JsonNode postingNode : cashFlowChangedNode.path("cash_flow")) {
+ var posting = new FinalCashFlowPosting();
+ posting.setSource(buildCashFlowAccount(postingNode.path("source")));
+ posting.setDestination(buildCashFlowAccount(postingNode.path("destination")));
+ posting.setVolume(buildCash(postingNode.path("volume")));
+ postings.add(posting);
+ }
+ var cashFlowChanged = new InvoicePaymentCashFlowChanged();
+ cashFlowChanged.setCashFlow(postings);
+ return cashFlowChanged;
+ }
+
+ private static InvoicePaymentStatusChanged buildStatusChanged(JsonNode statusChangedNode) {
+ var statusChanged = new InvoicePaymentStatusChanged();
+ statusChanged.setStatus(buildStatus(statusChangedNode.path("status")));
+ return statusChanged;
+ }
+
+ private static InvoicePaymentSessionChange buildTransactionBoundSessionChange(JsonNode sessionChangeNode) {
+ var trxNode = sessionChangeNode.path("payload").path("session_transaction_bound").path("trx");
+ var transactionInfo = new TransactionInfo();
+ transactionInfo.setId(trxNode.path("id").asText());
+ transactionInfo.setExtra(readStringMap(trxNode.path("extra")));
+
+ var additionalInfo = new AdditionalTransactionInfo();
+ if (trxNode.path("additional_info").has("rrn")) {
+ additionalInfo.setRrn(trxNode.path("additional_info").path("rrn").asText());
+ }
+ if (trxNode.path("additional_info").has("approval_code")) {
+ additionalInfo.setApprovalCode(trxNode.path("additional_info").path("approval_code").asText());
+ }
+ if (additionalInfo.isSetRrn() || additionalInfo.isSetApprovalCode()) {
+ transactionInfo.setAdditionalInfo(additionalInfo);
+ }
+
+ var transactionBound = new SessionTransactionBound();
+ transactionBound.setTrx(transactionInfo);
+ var payload = new SessionChangePayload();
+ payload.setSessionTransactionBound(transactionBound);
+ var target = new TargetInvoicePaymentStatus();
+ applyTargetStatus(target, sessionChangeNode.path("target"));
+ var sessionChange = new InvoicePaymentSessionChange();
+ sessionChange.setTarget(target);
+ sessionChange.setPayload(payload);
+ return sessionChange;
+ }
+
+ private static boolean isTransactionBoundSessionChange(JsonNode payloadNode) {
+ return payloadNode.has("invoice_payment_session_change")
+ && payloadNode.path("invoice_payment_session_change")
+ .path("payload")
+ .has("session_transaction_bound");
+ }
+
+ private static PaymentRoute buildRoute(JsonNode routeNode) {
+ var route = new PaymentRoute();
+ var provider = new ProviderRef();
+ provider.setId(routeNode.path("provider").path("id").asInt(INITIAL_PROVIDER_ID));
+ route.setProvider(provider);
+ var terminal = new TerminalRef();
+ terminal.setId(routeNode.path("terminal").path("id").asInt(INITIAL_TERMINAL_ID));
+ route.setTerminal(terminal);
+ return route;
+ }
+
+ private static FinalCashFlowAccount buildCashFlowAccount(JsonNode accountNode) {
+ var accountTypeNode = accountNode.path("account_type");
+ var accountType = new CashFlowAccount();
+ if (accountTypeNode.has("provider")) {
+ accountType.setProvider(ProviderCashFlowAccount.valueOf(accountTypeNode.path("provider").asText()));
+ }
+ if (accountTypeNode.has("merchant")) {
+ accountType.setMerchant(MerchantCashFlowAccount.valueOf(accountTypeNode.path("merchant").asText()));
+ }
+ if (accountTypeNode.has("system")) {
+ accountType.setSystem(SystemCashFlowAccount.valueOf(accountTypeNode.path("system").asText()));
+ }
+ var account = new FinalCashFlowAccount();
+ account.setAccountType(accountType);
+ return account;
+ }
+
+ private static Cash buildCash(JsonNode cashNode) {
+ var currency = new CurrencyRef();
+ currency.setSymbolicCode(cashNode.path("currency").path("symbolic_code").asText());
+ var cash = new Cash();
+ cash.setAmount(cashNode.path("amount").asLong());
+ cash.setCurrency(currency);
+ return cash;
+ }
+
+ private static InvoicePaymentStatus buildStatus(JsonNode statusNode) {
+ var status = new InvoicePaymentStatus();
+ var fields = statusNode.fieldNames();
+ if (!fields.hasNext()) {
+ return status;
+ }
+ var fieldName = fields.next();
+ switch (fieldName) {
+ case "pending" -> status.setPending(new InvoicePaymentPending());
+ case "processed" -> status.setProcessed(new InvoicePaymentProcessed());
+ case "captured" -> {
+ var captured = new InvoicePaymentCaptured();
+ var capturedNode = statusNode.path(fieldName);
+ if (capturedNode.has("reason")) {
+ captured.setReason(capturedNode.path("reason").asText());
+ }
+ if (capturedNode.has("cost")) {
+ captured.setCost(buildCash(capturedNode.path("cost")));
+ }
+ status.setCaptured(captured);
+ }
+ case "failed" -> {
+ var failedNode = statusNode.path(fieldName).path("failure");
+ var failureNode = failedNode.path("failure");
+ var failure = new Failure();
+ failure.setCode(failureNode.path("code").asText(null));
+ failure.setReason(failureNode.path("reason").asText(null));
+ if (failureNode.has("sub")) {
+ var subFailure = new SubFailure();
+ subFailure.setCode(failureNode.path("sub").path("code").asText(null));
+ failure.setSub(subFailure);
+ }
+ var operationFailure = new OperationFailure();
+ operationFailure.setFailure(failure);
+ var failed = new InvoicePaymentFailed();
+ failed.setFailure(operationFailure);
+ status.setFailed(failed);
+ }
+ default -> {
+ }
+ }
+ return status;
+ }
+
+ private static Map readStringMap(JsonNode objectNode) {
+ if (objectNode == null || !objectNode.isObject()) {
+ return Map.of();
+ }
+ var values = new LinkedHashMap();
+ objectNode.fields().forEachRemaining(entry -> values.put(entry.getKey(), entry.getValue().asText()));
+ return values;
+ }
+
+ private static void applyTargetStatus(TargetInvoicePaymentStatus target, JsonNode targetNode) {
+ var fields = targetNode.fieldNames();
+ if (!fields.hasNext()) {
+ target.setProcessed(new InvoicePaymentProcessed());
+ return;
+ }
+ switch (fields.next()) {
+ case "processed" -> target.setProcessed(new InvoicePaymentProcessed());
+ case "captured" -> target.setCaptured(new InvoicePaymentCaptured());
+ default -> target.setProcessed(new InvoicePaymentProcessed());
+ }
+ }
+
+ private static String readResource(String resourceName) throws IOException {
+ try (var inputStream = new ClassPathResource(resourceName).getInputStream()) {
+ return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private static String extractJsonArray(String content, String resourceName) {
+ var jsonStart = content.indexOf("\n[");
+ if (jsonStart >= 0) {
+ jsonStart++;
+ } else {
+ jsonStart = content.indexOf('[');
+ }
+ if (jsonStart < 0) {
+ throw new IllegalArgumentException("Fixture does not contain a JSON array: " + resourceName);
+ }
+ return content.substring(jsonStart);
+ }
+
+ private static String readPartyId(JsonNode paymentNode) {
+ var partyRefId = paymentNode.path("party_ref").path("id").asText(null);
+ return partyRefId != null ? partyRefId : paymentNode.path("owner_id").asText(null);
+ }
+
+ private static String readShopId(JsonNode paymentNode) {
+ var shopRefId = paymentNode.path("shop_ref").path("id").asText(null);
+ return shopRefId != null ? shopRefId : paymentNode.path("shop_id").asText(null);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/RealWithdrawalIngestionEventFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/RealWithdrawalIngestionEventFixtures.java
new file mode 100644
index 0000000..66c8c32
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/RealWithdrawalIngestionEventFixtures.java
@@ -0,0 +1,426 @@
+package dev.vality.ccreporter.fixture;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import dev.vality.ccreporter.serde.thrift.ThriftSerializer;
+import dev.vality.fistful.base.Cash;
+import dev.vality.fistful.base.CurrencyRef;
+import dev.vality.fistful.cashflow.FinalCashFlow;
+import dev.vality.fistful.cashflow.FinalCashFlowAccount;
+import dev.vality.fistful.cashflow.FinalCashFlowPosting;
+import dev.vality.fistful.transfer.CreatedChange;
+import dev.vality.fistful.transfer.Transfer;
+import dev.vality.fistful.withdrawal.*;
+import dev.vality.fistful.withdrawal.status.Pending;
+import dev.vality.fistful.withdrawal.status.Status;
+import dev.vality.fistful.withdrawal.status.Succeeded;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import dev.vality.machinegun.msgpack.Value;
+import org.apache.thrift.TBase;
+import org.springframework.core.io.ClassPathResource;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Загружает санитизированный production-like withdrawal flow из test resources
+ * и превращает его в MachineEvent batch.
+ */
+public final class RealWithdrawalIngestionEventFixtures {
+
+ public static final String WITHDRAWAL_ID = "211890";
+
+ private static final String RESOURCE_NAME = "withdrawals/211890_events.txt";
+ private static final List COLLECTION_RESOURCE_NAMES = List.of(
+ "withdrawals/211890_events.txt",
+ "withdrawals/257060.txt",
+ "withdrawals/257072.txt",
+ "withdrawals/257077.txt",
+ "withdrawals/257080.txt",
+ "withdrawals/257085.txt"
+ );
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final ThriftSerializer> THRIFT_SERIALIZER = new ThriftSerializer<>();
+
+ private RealWithdrawalIngestionEventFixtures() {
+ }
+
+ public static List withdrawalEvents() {
+ return withdrawalEvents(RESOURCE_NAME);
+ }
+
+ private static List withdrawalEvents(String resourceName) {
+ try {
+ var root = (ArrayNode) OBJECT_MAPPER.readTree(readResource(resourceName));
+ var sourceId = detectWithdrawalId(root, resourceName);
+ var machineEvents = new ArrayList(root.size());
+ for (JsonNode eventNode : root) {
+ buildEvent(eventNode).ifPresent(payload ->
+ machineEvents.add(new MachineEvent()
+ .setEventId(eventNode.path("event_id").asLong())
+ .setSourceId(sourceId)
+ .setSourceNs("withdrawals")
+ .setCreatedAt(eventNode.path("occured_at").asText())
+ .setData(Value.bin(serialize(payload))))
+ );
+ }
+ return machineEvents;
+ } catch (IOException ex) {
+ throw new UncheckedIOException("Failed to load real withdrawal ingestion fixture", ex);
+ }
+ }
+
+ public static List withdrawalCollectionEvents() {
+ return COLLECTION_RESOURCE_NAMES.stream()
+ .flatMap(resourceName -> withdrawalEvents(resourceName).stream())
+ .toList();
+ }
+
+ public static List withdrawalSessionCollectionEvents() {
+ return COLLECTION_RESOURCE_NAMES.stream()
+ .flatMap(resourceName -> withdrawalSessionEvents(resourceName).stream())
+ .toList();
+ }
+
+ static List collectionResourceNames() {
+ return COLLECTION_RESOURCE_NAMES;
+ }
+
+ private static Optional buildEvent(JsonNode eventNode) {
+ var changeNode = eventNode.path("change");
+ Change change = null;
+
+ if (changeNode.has("created")) {
+ change = new Change();
+ change.setCreated(buildCreated(changeNode.path("created")));
+ } else if (changeNode.has("route")) {
+ change = new Change();
+ change.setRoute(buildRouteChange(changeNode.path("route")));
+ } else if (changeNode.has("status_changed")) {
+ change = new Change();
+ change.setStatusChanged(buildStatusChanged(changeNode.path("status_changed")));
+ } else if (hasTransferCashFlow(changeNode)) {
+ change = new Change();
+ change.setTransfer(buildTransferChange(changeNode.path("transfer")));
+ }
+
+ if (change == null) {
+ return Optional.empty();
+ }
+
+ var timestampedChange = new TimestampedChange();
+ timestampedChange.setOccuredAt(eventNode.path("occured_at").asText());
+ timestampedChange.setChange(change);
+ return Optional.of(timestampedChange);
+ }
+
+ public static List withdrawalSessionEvents() {
+ return withdrawalSessionEvents(RESOURCE_NAME);
+ }
+
+ private static List withdrawalSessionEvents(String resourceName) {
+ try {
+ var root = (ArrayNode) OBJECT_MAPPER.readTree(readResource(resourceName));
+ var withdrawalId = detectWithdrawalId(root, resourceName);
+ var machineEvents = new ArrayList(root.size());
+ for (JsonNode eventNode : root) {
+ buildSessionEvent(root, eventNode, withdrawalId).ifPresent(sessionEvent ->
+ machineEvents.add(new MachineEvent()
+ .setEventId(eventNode.path("event_id").asLong())
+ .setSourceId(sessionEvent.sessionId())
+ .setSourceNs("withdrawal-sessions")
+ .setCreatedAt(eventNode.path("occured_at").asText())
+ .setData(Value.bin(serialize(sessionEvent.payload()))))
+ );
+ }
+ return machineEvents;
+ } catch (IOException ex) {
+ throw new UncheckedIOException("Failed to load real withdrawal-session ingestion fixture", ex);
+ }
+ }
+
+ private static Optional buildSessionEvent(
+ ArrayNode root,
+ JsonNode eventNode,
+ String withdrawalId
+ ) {
+ var sessionNode = eventNode.path("change").path("session");
+ if (sessionNode.isMissingNode()) {
+ return Optional.empty();
+ }
+ var sessionId = sessionNode.path("id").asText(null);
+ if (sessionId == null || sessionId.isBlank()) {
+ return Optional.empty();
+ }
+
+ var payloadNode = sessionNode.path("payload");
+ dev.vality.fistful.withdrawal_session.Change sessionChange = null;
+ if (payloadNode.has("started")) {
+ var route = resolveSessionRoute(root, eventNode.path("event_id").asLong());
+ var withdrawal = resolveSessionWithdrawal(root, withdrawalId);
+ if (route == null || withdrawal == null) {
+ return Optional.empty();
+ }
+ sessionChange = new dev.vality.fistful.withdrawal_session.Change();
+ sessionChange.setCreated(buildStartedSession(sessionId, withdrawal, route));
+ } else if (payloadNode.has("finished")) {
+ sessionChange = new dev.vality.fistful.withdrawal_session.Change();
+ sessionChange.setFinished(buildSessionResult(payloadNode.path("finished").path("result")));
+ }
+
+ if (sessionChange == null) {
+ return Optional.empty();
+ }
+
+ var timestampedChange = new dev.vality.fistful.withdrawal_session.TimestampedChange();
+ timestampedChange.setOccuredAt(eventNode.path("occured_at").asText());
+ timestampedChange.setChange(sessionChange);
+ return Optional.of(new RealSessionEvent(sessionId, timestampedChange));
+ }
+
+ private static dev.vality.fistful.withdrawal.CreatedChange buildCreated(JsonNode createdNode) {
+ var withdrawalNode = createdNode.path("withdrawal");
+ var withdrawal = new Withdrawal();
+ withdrawal.setId(withdrawalNode.path("id").asText());
+ withdrawal.setPartyId(withdrawalNode.path("party_id").asText(null));
+ withdrawal.setWalletId(withdrawalNode.path("wallet_id").asText(null));
+ withdrawal.setDestinationId(withdrawalNode.path("destination_id").asText(null));
+ withdrawal.setCreatedAt(withdrawalNode.path("created_at").asText());
+ withdrawal.setDomainRevision(withdrawalNode.path("domain_revision").asLong());
+ withdrawal.setExternalId(withdrawalNode.path("external_id").asText(null));
+ withdrawal.setBody(buildCash(withdrawalNode.path("body")));
+
+ var created = new dev.vality.fistful.withdrawal.CreatedChange();
+ created.setWithdrawal(withdrawal);
+ return created;
+ }
+
+ private static dev.vality.fistful.withdrawal_session.Session buildStartedSession(
+ String sessionId,
+ dev.vality.fistful.withdrawal_session.Withdrawal withdrawal,
+ dev.vality.fistful.withdrawal_session.Route route
+ ) {
+ var sessionStatus = new dev.vality.fistful.withdrawal_session.SessionStatus();
+ sessionStatus.setActive(new dev.vality.fistful.withdrawal_session.SessionActive());
+
+ return new dev.vality.fistful.withdrawal_session.Session()
+ .setId(sessionId)
+ .setStatus(sessionStatus)
+ .setRoute(route)
+ .setWithdrawal(withdrawal);
+ }
+
+ private static dev.vality.fistful.withdrawal_session.SessionResult buildSessionResult(JsonNode resultNode) {
+ var sessionResult = new dev.vality.fistful.withdrawal_session.SessionResult();
+ if (resultNode.has("succeeded")) {
+ sessionResult.setSuccess(new dev.vality.fistful.withdrawal_session.SessionResultSuccess());
+ } else if (resultNode.has("failed")) {
+ var failed = new dev.vality.fistful.withdrawal_session.SessionResultFailed();
+ failed.setFailure(buildFailure(resultNode.path("failed").path("failure")));
+ sessionResult.setFailed(failed);
+ }
+ return sessionResult;
+ }
+
+ private static RouteChange buildRouteChange(JsonNode routeNode) {
+ var route = new Route();
+ route.setProviderId(routeNode.path("route").path("provider_id").asInt());
+ route.setTerminalId(routeNode.path("route").path("terminal_id").asInt());
+ var routeChange = new RouteChange();
+ routeChange.setRoute(route);
+ return routeChange;
+ }
+
+ private static StatusChange buildStatusChanged(JsonNode statusChangedNode) {
+ var statusNode = statusChangedNode.path("status");
+ var status = new Status();
+ if (statusNode.has("succeeded")) {
+ status.setSucceeded(new Succeeded());
+ } else if (statusNode.has("pending")) {
+ status.setPending(new Pending());
+ }
+ var statusChange = new StatusChange();
+ statusChange.setStatus(status);
+ return statusChange;
+ }
+
+ private static TransferChange buildTransferChange(JsonNode transferNode) {
+ var postingsNode = transferNode.path("payload")
+ .path("created")
+ .path("transfer")
+ .path("cashflow")
+ .path("postings");
+
+ var postings = new ArrayList(postingsNode.size());
+ for (JsonNode postingNode : postingsNode) {
+ var posting = new FinalCashFlowPosting();
+ posting.setSource(buildAccount(postingNode.path("source")));
+ posting.setDestination(buildAccount(postingNode.path("destination")));
+ posting.setVolume(buildCash(postingNode.path("volume")));
+ postings.add(posting);
+ }
+
+ var cashFlow = new FinalCashFlow();
+ cashFlow.setPostings(postings);
+
+ var transfer = new Transfer();
+ transfer.setId(transferNode.path("payload").path("created").path("transfer").path("id").asText());
+ transfer.setCashflow(cashFlow);
+
+ var created = new CreatedChange();
+ created.setTransfer(transfer);
+
+ var payload = new dev.vality.fistful.transfer.Change();
+ payload.setCreated(created);
+
+ var transferChange = new TransferChange();
+ transferChange.setPayload(payload);
+ return transferChange;
+ }
+
+ private static FinalCashFlowAccount buildAccount(JsonNode accountNode) {
+ var account = new FinalCashFlowAccount();
+ var accountType = new dev.vality.fistful.cashflow.CashFlowAccount();
+ var accountTypeNode = accountNode.path("account_type");
+ if (accountTypeNode.has("wallet")) {
+ accountType.setWallet(dev.vality.fistful.cashflow.WalletCashFlowAccount.valueOf(
+ accountTypeNode.path("wallet").asText()
+ ));
+ } else if (accountTypeNode.has("system")) {
+ accountType.setSystem(dev.vality.fistful.cashflow.SystemCashFlowAccount.valueOf(
+ accountTypeNode.path("system").asText()
+ ));
+ } else if (accountTypeNode.has("provider")) {
+ accountType.setProvider(dev.vality.fistful.cashflow.ProviderCashFlowAccount.valueOf(
+ accountTypeNode.path("provider").asText()
+ ));
+ }
+ account.setAccountType(accountType);
+ return account;
+ }
+
+ private static Cash buildCash(JsonNode cashNode) {
+ var currency = new CurrencyRef();
+ currency.setSymbolicCode(cashNode.path("currency").path("symbolic_code").asText());
+ var cash = new Cash();
+ cash.setAmount(cashNode.path("amount").asLong());
+ cash.setCurrency(currency);
+ return cash;
+ }
+
+ private static boolean hasTransferCashFlow(JsonNode changeNode) {
+ return changeNode.has("transfer")
+ && changeNode.path("transfer").path("payload").has("created")
+ && changeNode.path("transfer").path("payload").path("created").path("transfer").has("cashflow");
+ }
+
+ private static byte[] serialize(TBase, ?> payload) {
+ return THRIFT_SERIALIZER.serialize("", payload);
+ }
+
+ private static String readResource(String resourceName) throws IOException {
+ try (var inputStream = new ClassPathResource(resourceName).getInputStream()) {
+ return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private static String detectWithdrawalId(ArrayNode root, String resourceName) {
+ for (JsonNode eventNode : root) {
+ var createdNode = eventNode.path("change").path("created").path("withdrawal").path("id");
+ if (!createdNode.isMissingNode()) {
+ return createdNode.asText();
+ }
+ }
+ throw new IllegalArgumentException("Failed to detect withdrawal_id from fixture: " + resourceName);
+ }
+
+ private static dev.vality.fistful.withdrawal_session.Route resolveSessionRoute(ArrayNode root, long eventId) {
+ dev.vality.fistful.withdrawal_session.Route route = null;
+ for (JsonNode candidateEventNode : root) {
+ if (candidateEventNode.path("event_id").asLong() > eventId) {
+ break;
+ }
+ var routeNode = candidateEventNode.path("change").path("route").path("route");
+ if (routeNode.isMissingNode()) {
+ continue;
+ }
+ route = new dev.vality.fistful.withdrawal_session.Route()
+ .setProviderId(routeNode.path("provider_id").asInt())
+ .setTerminalId(routeNode.path("terminal_id").asInt());
+ }
+ return route;
+ }
+
+ private static dev.vality.fistful.withdrawal_session.Withdrawal resolveSessionWithdrawal(
+ ArrayNode root,
+ String withdrawalId
+ ) {
+ dev.vality.fistful.base.Cash cash = null;
+ dev.vality.fistful.base.Resource destinationResource = null;
+ for (JsonNode candidateEventNode : root) {
+ var changeNode = candidateEventNode.path("change");
+ var createdWithdrawalNode = changeNode.path("created").path("withdrawal");
+ if (!createdWithdrawalNode.isMissingNode() && !createdWithdrawalNode.isEmpty()) {
+ cash = buildCash(createdWithdrawalNode.path("body"));
+ }
+ var destinationResourceNode = changeNode.path("resource").path("got").path("resource");
+ if (!destinationResourceNode.isMissingNode() && !destinationResourceNode.isEmpty()) {
+ destinationResource = buildDestinationResource(destinationResourceNode);
+ }
+ }
+ if (cash == null || destinationResource == null) {
+ return null;
+ }
+ return new dev.vality.fistful.withdrawal_session.Withdrawal()
+ .setId(withdrawalId)
+ .setCash(cash)
+ .setDestinationResource(destinationResource);
+ }
+
+ private static dev.vality.fistful.base.Resource buildDestinationResource(JsonNode resourceNode) {
+ var bankCardNode = resourceNode.path("bank_card").path("bank_card");
+ var bankCard = new dev.vality.fistful.base.BankCard();
+ bankCard.setToken(bankCardNode.path("token").asText(null));
+ bankCard.setBin(bankCardNode.path("bin").asText(null));
+ bankCard.setMaskedPan(bankCardNode.path("masked_pan").asText(null));
+
+ var resourceBankCard = new dev.vality.fistful.base.ResourceBankCard();
+ resourceBankCard.setBankCard(bankCard);
+
+ var resource = new dev.vality.fistful.base.Resource();
+ resource.setBankCard(resourceBankCard);
+ return resource;
+ }
+
+ private static dev.vality.fistful.base.Failure buildFailure(JsonNode failureNode) {
+ var failure = new dev.vality.fistful.base.Failure();
+ failure.setCode(failureNode.path("code").asText(null));
+ failure.setReason(failureNode.path("reason").asText(null));
+ var subNode = failureNode.path("sub");
+ if (!subNode.isMissingNode() && !subNode.isEmpty()) {
+ failure.setSub(buildSubFailure(subNode));
+ }
+ return failure;
+ }
+
+ private static dev.vality.fistful.base.SubFailure buildSubFailure(JsonNode failureNode) {
+ var subFailure = new dev.vality.fistful.base.SubFailure();
+ subFailure.setCode(failureNode.path("code").asText(null));
+ var nestedSubNode = failureNode.path("sub");
+ if (!nestedSubNode.isMissingNode() && !nestedSubNode.isEmpty()) {
+ subFailure.setSub(buildSubFailure(nestedSubNode));
+ }
+ return subFailure;
+ }
+
+ private record RealSessionEvent(
+ String sessionId,
+ dev.vality.fistful.withdrawal_session.TimestampedChange payload
+ ) {
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/ReportRecordFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/ReportRecordFixtures.java
new file mode 100644
index 0000000..b1946dc
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/ReportRecordFixtures.java
@@ -0,0 +1,129 @@
+package dev.vality.ccreporter.fixture;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+/**
+ * Подготавливает записи о заданиях и файлах отчётов для сценариев, которым нужен уже заданный status в базе.
+ */
+public final class ReportRecordFixtures {
+
+ private ReportRecordFixtures() {
+ }
+
+ public static void markReportProcessing(
+ JdbcTemplate jdbcTemplate,
+ long reportId,
+ Instant startedAt,
+ Instant snapshotFixedAt
+ ) {
+ jdbcTemplate.update(
+ """
+ UPDATE ccr.report_job
+ SET status = 'processing',
+ started_at = ?,
+ data_snapshot_fixed_at = ?
+ WHERE id = ?
+ """,
+ toUtcLocalDateTime(startedAt),
+ toUtcLocalDateTime(snapshotFixedAt),
+ reportId
+ );
+ }
+
+ public static void markReportCreated(
+ JdbcTemplate jdbcTemplate,
+ long reportId,
+ Instant startedAt,
+ Instant snapshotFixedAt,
+ Instant finishedAt,
+ Instant expiresAt,
+ long rowsCount
+ ) {
+ jdbcTemplate.update(
+ """
+ UPDATE ccr.report_job
+ SET status = 'created',
+ started_at = ?,
+ data_snapshot_fixed_at = ?,
+ finished_at = ?,
+ expires_at = ?,
+ rows_count = ?
+ WHERE id = ?
+ """,
+ toUtcLocalDateTime(startedAt),
+ toUtcLocalDateTime(snapshotFixedAt),
+ toUtcLocalDateTime(finishedAt),
+ toUtcLocalDateTime(expiresAt),
+ rowsCount,
+ reportId
+ );
+ }
+
+ public static void markReportFailed(
+ JdbcTemplate jdbcTemplate,
+ long reportId,
+ Instant startedAt,
+ Instant snapshotFixedAt,
+ Instant finishedAt,
+ String errorCode,
+ String errorMessage
+ ) {
+ jdbcTemplate.update(
+ """
+ UPDATE ccr.report_job
+ SET status = 'failed',
+ started_at = ?,
+ data_snapshot_fixed_at = ?,
+ finished_at = ?,
+ error_code = ?,
+ error_message = ?
+ WHERE id = ?
+ """,
+ toUtcLocalDateTime(startedAt),
+ toUtcLocalDateTime(snapshotFixedAt),
+ toUtcLocalDateTime(finishedAt),
+ errorCode,
+ errorMessage,
+ reportId
+ );
+ }
+
+ public static void attachCsvFile(
+ JdbcTemplate jdbcTemplate,
+ long reportId,
+ String fileId,
+ Instant createdAt
+ ) {
+ jdbcTemplate.update(
+ """
+ INSERT INTO ccr.report_file (
+ report_id,
+ file_id,
+ file_type,
+ filename,
+ content_type,
+ size_bytes,
+ md5,
+ sha256,
+ created_at
+ )
+ VALUES (?, ?, 'csv', ?, 'text/csv', ?, ?, ?, ?)
+ """,
+ reportId,
+ fileId,
+ "payments.csv",
+ 128L,
+ "md5-value",
+ "sha256-value",
+ toUtcLocalDateTime(createdAt)
+ );
+ }
+
+ private static LocalDateTime toUtcLocalDateTime(Instant value) {
+ return LocalDateTime.ofInstant(value, ZoneOffset.UTC);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/ReportRequestFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/ReportRequestFixtures.java
new file mode 100644
index 0000000..bfab5be
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/ReportRequestFixtures.java
@@ -0,0 +1,61 @@
+package dev.vality.ccreporter.fixture;
+
+import dev.vality.ccreporter.*;
+
+import java.time.Instant;
+
+/**
+ * Держит заготовки запросов на отчёт, чтобы тесты читались как сценарии, а не как сборка Thrift-структур по кускам.
+ */
+public final class ReportRequestFixtures {
+
+ private ReportRequestFixtures() {
+ }
+
+ public static CreateReportRequest payments(String idempotencyKey) {
+ return payments(idempotencyKey, defaultTimeRange());
+ }
+
+ public static CreateReportRequest payments(String idempotencyKey, TimeRange timeRange) {
+ var paymentsQuery = new PaymentsQuery();
+ paymentsQuery.setTimeRange(timeRange);
+ var reportQuery = new ReportQuery();
+ reportQuery.setPayments(paymentsQuery);
+
+ var request = new CreateReportRequest();
+ request.setReportType(ReportType.payments);
+ request.setFileType(FileType.csv);
+ request.setQuery(reportQuery);
+ request.setIdempotencyKey(idempotencyKey);
+ return request;
+ }
+
+ public static CreateReportRequest withdrawals(String idempotencyKey) {
+ return withdrawals(idempotencyKey, defaultTimeRange());
+ }
+
+ public static CreateReportRequest withdrawals(String idempotencyKey, TimeRange timeRange) {
+ var withdrawalsQuery = new WithdrawalsQuery();
+ withdrawalsQuery.setTimeRange(timeRange);
+ var reportQuery = new ReportQuery();
+ reportQuery.setWithdrawals(withdrawalsQuery);
+
+ var request = new CreateReportRequest();
+ request.setReportType(ReportType.withdrawals);
+ request.setFileType(FileType.csv);
+ request.setQuery(reportQuery);
+ request.setIdempotencyKey(idempotencyKey);
+ return request;
+ }
+
+ public static TimeRange defaultTimeRange() {
+ return timeRange("2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z");
+ }
+
+ public static TimeRange timeRange(String from, String to) {
+ return new TimeRange(
+ Instant.parse(from).toString(),
+ Instant.parse(to).toString()
+ );
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/SerializedIngestionEventFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/SerializedIngestionEventFixtures.java
new file mode 100644
index 0000000..eb71c24
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/SerializedIngestionEventFixtures.java
@@ -0,0 +1,76 @@
+package dev.vality.ccreporter.fixture;
+
+import dev.vality.machinegun.eventsink.MachineEvent;
+
+import java.util.List;
+
+/**
+ * Сохраняет старую точку входа для ingestion fixtures, но сами события уже разложены по отдельным классам.
+ */
+public final class SerializedIngestionEventFixtures {
+
+ public static final String PAYMENT_INVOICE_ID = PaymentIngestionEventFixtures.PAYMENT_INVOICE_ID;
+ public static final String PAYMENT_ID = PaymentIngestionEventFixtures.PAYMENT_ID;
+ public static final String REAL_PAYMENT_INVOICE_ID = RealPaymentIngestionEventFixtures.PAYMENT_INVOICE_ID;
+ public static final String REAL_PAYMENT_ID = RealPaymentIngestionEventFixtures.PAYMENT_ID;
+ public static final String LEGACY_PAYMENT_INVOICE_ID = RealPaymentIngestionEventFixtures.LEGACY_PAYMENT_INVOICE_ID;
+ public static final String LEGACY_PAYMENT_ID = RealPaymentIngestionEventFixtures.LEGACY_PAYMENT_ID;
+ public static final String WITHDRAWAL_ID = WithdrawalIngestionEventFixtures.WITHDRAWAL_ID;
+ public static final String WITHDRAWAL_SESSION_ID = WithdrawalIngestionEventFixtures.WITHDRAWAL_SESSION_ID;
+ public static final String REAL_WITHDRAWAL_ID = RealWithdrawalIngestionEventFixtures.WITHDRAWAL_ID;
+
+ private SerializedIngestionEventFixtures() {
+ }
+
+ public static List paymentEvents() {
+ return PaymentIngestionEventFixtures.paymentEvents();
+ }
+
+ public static List paymentProxyStateFallbackEvents() {
+ return PaymentIngestionEventFixtures.paymentProxyStateFallbackEvents();
+ }
+
+ public static List failedPaymentEvents() {
+ return PaymentIngestionEventFixtures.failedPaymentEvents();
+ }
+
+ public static List paymentChangesCombinedInSingleEvent() {
+ return PaymentIngestionEventFixtures.paymentChangesCombinedInSingleEvent();
+ }
+
+ public static List realPaymentEvents() {
+ return RealPaymentIngestionEventFixtures.paymentEvents();
+ }
+
+ public static List legacyPaymentEvents() {
+ return RealPaymentIngestionEventFixtures.legacyPaymentEvents();
+ }
+
+ public static List paymentCollectionEvents() {
+ return RealPaymentIngestionEventFixtures.paymentCollectionEvents();
+ }
+
+ public static List withdrawalEvents() {
+ return WithdrawalIngestionEventFixtures.withdrawalEvents();
+ }
+
+ public static List realWithdrawalEvents() {
+ return RealWithdrawalIngestionEventFixtures.withdrawalEvents();
+ }
+
+ public static List withdrawalCollectionEvents() {
+ return RealWithdrawalIngestionEventFixtures.withdrawalCollectionEvents();
+ }
+
+ public static List realWithdrawalSessionEvents() {
+ return RealWithdrawalIngestionEventFixtures.withdrawalSessionEvents();
+ }
+
+ public static List withdrawalSessionCollectionEvents() {
+ return RealWithdrawalIngestionEventFixtures.withdrawalSessionCollectionEvents();
+ }
+
+ public static List withdrawalSessionEvents() {
+ return WithdrawalIngestionEventFixtures.withdrawalSessionEvents();
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/fixture/WithdrawalIngestionEventFixtures.java b/src/test/java/dev/vality/ccreporter/fixture/WithdrawalIngestionEventFixtures.java
new file mode 100644
index 0000000..dd43384
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/fixture/WithdrawalIngestionEventFixtures.java
@@ -0,0 +1,266 @@
+package dev.vality.ccreporter.fixture;
+
+import dev.vality.ccreporter.serde.thrift.ThriftSerializer;
+import dev.vality.fistful.cashflow.FinalCashFlow;
+import dev.vality.fistful.transfer.CreatedChange;
+import dev.vality.fistful.transfer.Transfer;
+import dev.vality.fistful.withdrawal.*;
+import dev.vality.fistful.withdrawal.status.Status;
+import dev.vality.fistful.withdrawal_session.Session;
+import dev.vality.fistful.withdrawal_session.TransactionBoundChange;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import dev.vality.machinegun.msgpack.Value;
+import org.apache.thrift.TBase;
+
+import java.util.List;
+
+/**
+ * Собирает serialized events для withdrawal и withdrawal-session потока, чтобы эта рутина не жила прямо в тестах.
+ */
+public final class WithdrawalIngestionEventFixtures {
+
+ public static final String WITHDRAWAL_ID = "withdrawal-serialized";
+ public static final String WITHDRAWAL_SOURCE_ID = "withdrawal-serialized";
+ public static final String WITHDRAWAL_SESSION_ID = "session-serialized";
+
+ private static final ThriftSerializer> THRIFT_SERIALIZER = new ThriftSerializer<>();
+
+
+ private WithdrawalIngestionEventFixtures() {
+ }
+
+ public static List withdrawalEvents() {
+ return List.of(
+ withdrawalMachineEvent(1L, withdrawalCreatedTimestampedChange()),
+ withdrawalMachineEvent(2L, withdrawalTransferTimestampedChange()),
+ withdrawalMachineEvent(5L, withdrawalStatusTimestampedChange())
+ );
+ }
+
+ public static List withdrawalSessionEvents() {
+ return List.of(
+ withdrawalSessionMachineEvent(3L, withdrawalSessionCreatedEvent()),
+ withdrawalSessionMachineEvent(4L, withdrawalSessionTransactionBoundEvent())
+ );
+ }
+
+ private static MachineEvent withdrawalMachineEvent(long eventId, TimestampedChange payload) {
+ return new MachineEvent()
+ .setEventId(eventId)
+ .setSourceId(WITHDRAWAL_ID)
+ .setSourceNs("withdrawals")
+ .setCreatedAt("2026-01-01T00:0" + eventId + ":00Z")
+ .setData(Value.bin(serialize(payload)));
+ }
+
+ private static MachineEvent withdrawalSessionMachineEvent(
+ long eventId,
+ dev.vality.fistful.withdrawal_session.TimestampedChange payload
+ ) {
+ return new MachineEvent()
+ .setEventId(eventId)
+ .setSourceId(WITHDRAWAL_SESSION_ID)
+ .setSourceNs("withdrawal-sessions")
+ .setCreatedAt("2026-01-01T00:0" + eventId + ":00Z")
+ .setData(Value.bin(serialize(payload)));
+ }
+
+ private static byte[] serialize(TBase, ?> payload) {
+ return THRIFT_SERIALIZER.serialize("", payload);
+ }
+
+ private static TimestampedChange withdrawalCreatedTimestampedChange() {
+ dev.vality.fistful.base.CurrencyRef rub = new dev.vality.fistful.base.CurrencyRef();
+ rub.setSymbolicCode("RUB");
+
+ dev.vality.fistful.base.Cash body = new dev.vality.fistful.base.Cash();
+ body.setAmount(1000L);
+ body.setCurrency(rub);
+
+ var route = new Route();
+ route.setProviderId(300);
+ route.setTerminalId(400);
+
+ dev.vality.fistful.base.CurrencyRef usd = new dev.vality.fistful.base.CurrencyRef();
+ usd.setSymbolicCode("USD");
+
+ dev.vality.fistful.base.Cash cashFrom = new dev.vality.fistful.base.Cash();
+ cashFrom.setAmount(1200L);
+ cashFrom.setCurrency(usd);
+
+ dev.vality.fistful.base.Cash cashTo = new dev.vality.fistful.base.Cash();
+ cashTo.setAmount(1000L);
+ cashTo.setCurrency(rub.deepCopy());
+
+ var quoteState = new QuoteState();
+ quoteState.setCashFrom(cashFrom);
+ quoteState.setCashTo(cashTo);
+ quoteState.setCreatedAt("2026-01-01T00:00:00Z");
+ quoteState.setExpiresOn("2026-01-01T01:00:00Z");
+
+ var withdrawal = new Withdrawal();
+ withdrawal.setId(WITHDRAWAL_ID);
+ withdrawal.setPartyId("party-serialized");
+ withdrawal.setWalletId("wallet-serialized");
+ withdrawal.setDestinationId("destination-serialized");
+ withdrawal.setCreatedAt("2026-01-01T00:00:00Z");
+ withdrawal.setExternalId("external-withdrawal-1");
+ withdrawal.setBody(body);
+ withdrawal.setRoute(route);
+ withdrawal.setQuote(quoteState);
+
+ dev.vality.fistful.withdrawal.CreatedChange createdChange =
+ new dev.vality.fistful.withdrawal.CreatedChange();
+ createdChange.setWithdrawal(withdrawal);
+
+ var change = new dev.vality.fistful.withdrawal.Change();
+ change.setCreated(createdChange);
+
+ var timestampedChange = new TimestampedChange();
+ timestampedChange.setOccuredAt("2026-01-01T00:01:00Z");
+ timestampedChange.setChange(change);
+ return timestampedChange;
+ }
+
+ private static TimestampedChange withdrawalTransferTimestampedChange() {
+ dev.vality.fistful.cashflow.CashFlowAccount walletAccountType =
+ new dev.vality.fistful.cashflow.CashFlowAccount();
+ walletAccountType.setWallet(dev.vality.fistful.cashflow.WalletCashFlowAccount.sender_settlement);
+ dev.vality.fistful.cashflow.FinalCashFlowAccount walletAccount =
+ new dev.vality.fistful.cashflow.FinalCashFlowAccount();
+ walletAccount.setAccountType(walletAccountType);
+
+ dev.vality.fistful.cashflow.CashFlowAccount systemAccountType =
+ new dev.vality.fistful.cashflow.CashFlowAccount();
+ systemAccountType.setSystem(dev.vality.fistful.cashflow.SystemCashFlowAccount.settlement);
+ dev.vality.fistful.cashflow.FinalCashFlowAccount systemAccount =
+ new dev.vality.fistful.cashflow.FinalCashFlowAccount();
+ systemAccount.setAccountType(systemAccountType);
+
+ dev.vality.fistful.base.CurrencyRef rub = new dev.vality.fistful.base.CurrencyRef();
+ rub.setSymbolicCode("RUB");
+
+ dev.vality.fistful.base.Cash feeCash = new dev.vality.fistful.base.Cash();
+ feeCash.setAmount(20L);
+ feeCash.setCurrency(rub);
+
+ dev.vality.fistful.cashflow.FinalCashFlowPosting feePosting =
+ new dev.vality.fistful.cashflow.FinalCashFlowPosting();
+ feePosting.setSource(walletAccount);
+ feePosting.setDestination(systemAccount);
+ feePosting.setVolume(feeCash);
+
+ var finalCashFlow = new FinalCashFlow();
+ finalCashFlow.setPostings(List.of(feePosting));
+
+ var transfer = new Transfer();
+ transfer.setId("transfer-serialized");
+ transfer.setCashflow(finalCashFlow);
+
+ var createdChange = new CreatedChange();
+ createdChange.setTransfer(transfer);
+
+ dev.vality.fistful.transfer.Change transferPayload = new dev.vality.fistful.transfer.Change();
+ transferPayload.setCreated(createdChange);
+
+ var transferChange = new TransferChange();
+ transferChange.setPayload(transferPayload);
+
+ var change = new dev.vality.fistful.withdrawal.Change();
+ change.setTransfer(transferChange);
+
+ var timestampedChange = new TimestampedChange();
+ timestampedChange.setOccuredAt("2026-01-01T00:02:00Z");
+ timestampedChange.setChange(change);
+ return timestampedChange;
+ }
+
+ private static TimestampedChange withdrawalStatusTimestampedChange() {
+ var status = new Status();
+ status.setSucceeded(new dev.vality.fistful.withdrawal.status.Succeeded());
+
+ var statusChange = new StatusChange();
+ statusChange.setStatus(status);
+
+ var change = new dev.vality.fistful.withdrawal.Change();
+ change.setStatusChanged(statusChange);
+
+ var timestampedChange = new TimestampedChange();
+ timestampedChange.setOccuredAt("2026-01-01T00:05:00Z");
+ timestampedChange.setChange(change);
+ return timestampedChange;
+ }
+
+ private static dev.vality.fistful.withdrawal_session.TimestampedChange withdrawalSessionCreatedEvent() {
+ dev.vality.fistful.withdrawal_session.Route route = new dev.vality.fistful.withdrawal_session.Route();
+ route.setProviderId(300);
+ route.setTerminalId(400);
+
+ dev.vality.fistful.withdrawal_session.SessionStatus sessionStatus =
+ new dev.vality.fistful.withdrawal_session.SessionStatus();
+ sessionStatus.setActive(new dev.vality.fistful.withdrawal_session.SessionActive());
+
+ dev.vality.fistful.base.CurrencyRef rub = new dev.vality.fistful.base.CurrencyRef();
+ rub.setSymbolicCode("RUB");
+
+ dev.vality.fistful.base.Cash cash = new dev.vality.fistful.base.Cash();
+ cash.setAmount(1000L);
+ cash.setCurrency(rub);
+
+ dev.vality.fistful.base.BankCard bankCard = new dev.vality.fistful.base.BankCard();
+ bankCard.setToken("token");
+ bankCard.setBin("411111");
+ bankCard.setMaskedPan("411111****1111");
+
+ dev.vality.fistful.base.ResourceBankCard resourceBankCard =
+ new dev.vality.fistful.base.ResourceBankCard();
+ resourceBankCard.setBankCard(bankCard);
+
+ dev.vality.fistful.base.Resource destinationResource = new dev.vality.fistful.base.Resource();
+ destinationResource.setBankCard(resourceBankCard);
+
+ dev.vality.fistful.withdrawal_session.Withdrawal withdrawal =
+ new dev.vality.fistful.withdrawal_session.Withdrawal();
+ withdrawal.setId(WITHDRAWAL_ID);
+ withdrawal.setCash(cash);
+ withdrawal.setDestinationResource(destinationResource);
+
+ Session session = new Session();
+ session.setId(WITHDRAWAL_SESSION_ID);
+ session.setRoute(route);
+ session.setStatus(sessionStatus);
+ session.setWithdrawal(withdrawal);
+
+ var change = new dev.vality.fistful.withdrawal_session.Change();
+ change.setCreated(session);
+
+ dev.vality.fistful.withdrawal_session.TimestampedChange timestampedChange =
+ new dev.vality.fistful.withdrawal_session.TimestampedChange();
+ timestampedChange.setOccuredAt("2026-01-01T00:03:00Z");
+ timestampedChange.setChange(change);
+ return timestampedChange;
+ }
+
+ private static dev.vality.fistful.withdrawal_session.TimestampedChange withdrawalSessionTransactionBoundEvent() {
+ dev.vality.fistful.base.AdditionalTransactionInfo additionalInfo =
+ new dev.vality.fistful.base.AdditionalTransactionInfo();
+ additionalInfo.setRrn("rrn-withdrawal-1");
+
+ dev.vality.fistful.base.TransactionInfo trxInfo = new dev.vality.fistful.base.TransactionInfo();
+ trxInfo.setId("trx-withdrawal-1");
+ trxInfo.setExtra(java.util.Map.of());
+ trxInfo.setAdditionalInfo(additionalInfo);
+
+ TransactionBoundChange transactionBoundChange = new TransactionBoundChange();
+ transactionBoundChange.setTrxInfo(trxInfo);
+
+ var change = new dev.vality.fistful.withdrawal_session.Change();
+ change.setTransactionBound(transactionBoundChange);
+
+ dev.vality.fistful.withdrawal_session.TimestampedChange timestampedChange =
+ new dev.vality.fistful.withdrawal_session.TimestampedChange();
+ timestampedChange.setOccuredAt("2026-01-01T00:04:00Z");
+ timestampedChange.setChange(change);
+ return timestampedChange;
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalEventProjectorTest.java b/src/test/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalEventProjectorTest.java
new file mode 100644
index 0000000..9cc6272
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalEventProjectorTest.java
@@ -0,0 +1,41 @@
+package dev.vality.ccreporter.ingestion.withdrawal;
+
+import dev.vality.fistful.base.Cash;
+import dev.vality.fistful.base.CurrencyRef;
+import dev.vality.fistful.withdrawal.BodyChange;
+import dev.vality.fistful.withdrawal.Change;
+import dev.vality.fistful.withdrawal.TimestampedChange;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WithdrawalEventProjectorTest {
+
+ private final WithdrawalEventProjector projector = new WithdrawalEventProjector();
+
+ @Test
+ void bodyChangedUsesNewBodyAmountAndCurrency() {
+ var payload = new TimestampedChange()
+ .setOccuredAt("2026-08-20T11:20:00Z")
+ .setChange(Change.body_changed(
+ new BodyChange()
+ .setOldBody(new Cash()
+ .setAmount(1000L)
+ .setCurrency(new CurrencyRef("RUB")))
+ .setNewBody(new Cash()
+ .setAmount(2000L)
+ .setCurrency(new CurrencyRef("USD")))
+ ));
+ var event = new MachineEvent()
+ .setEventId(2L)
+ .setSourceId("withdrawal-1")
+ .setCreatedAt("2026-08-20T11:20:01Z");
+
+ var updates = projector.project(event, payload);
+
+ assertThat(updates).hasSize(1);
+ assertThat(updates.getFirst().getAmount()).isEqualTo(2000L);
+ assertThat(updates.getFirst().getCurrency()).isEqualTo("USD");
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/CurrentStateDaoIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/CurrentStateDaoIntegrationTest.java
new file mode 100644
index 0000000..cda8184
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/CurrentStateDaoIntegrationTest.java
@@ -0,0 +1,270 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.dao.DominantLookupDao;
+import dev.vality.ccreporter.dao.PaymentTxnCurrentDao;
+import dev.vality.ccreporter.dao.WithdrawalSessionDao;
+import dev.vality.ccreporter.dao.WithdrawalTxnCurrentDao;
+import dev.vality.ccreporter.fixture.CurrentStateUpdateFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Objects;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Проверяет, как DAO обновляют current state и что старые события не перетирают более свежие.
+ */
+class CurrentStateDaoIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Autowired
+ private PaymentTxnCurrentDao paymentTxnCurrentDao;
+
+ @Autowired
+ private WithdrawalTxnCurrentDao withdrawalTxnCurrentDao;
+
+ @Autowired
+ private WithdrawalSessionDao withdrawalSessionDao;
+
+ @Autowired
+ private DominantLookupDao dominantLookupDao;
+
+ @Test
+ void paymentUpsertIsMonotonic() {
+ var finalizedAt = Instant.parse("2026-01-01T10:10:00Z");
+ var laterFinalizedAt = Instant.parse("2026-01-01T10:20:00Z");
+
+ paymentTxnCurrentDao.upsert(CurrentStateUpdateFixtures.paymentUpdate(10L, "captured", finalizedAt));
+ paymentTxnCurrentDao.upsert(CurrentStateUpdateFixtures.paymentUpdate(11L, "refunded", laterFinalizedAt));
+ paymentTxnCurrentDao.upsert(CurrentStateUpdateFixtures.paymentUpdate(9L, "pending", null));
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT domain_event_id, status, finalized_at
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = 'invoice-1' AND payment_id = 'payment-1'
+ """
+ );
+
+ assertThat(row.get("domain_event_id")).isEqualTo(11L);
+ assertThat(row.get("status")).isEqualTo("refunded");
+ assertThat(((Timestamp) Objects.requireNonNull(row.get("finalized_at"))).toLocalDateTime())
+ .isEqualTo(LocalDateTime.ofInstant(laterFinalizedAt, ZoneOffset.UTC));
+ }
+
+ @Test
+ void paymentUpsertUsesEventIdInsteadOfEventTimestampForOrdering() {
+ var event10 = CurrentStateUpdateFixtures.paymentUpdate(10L, "pending", null)
+ .setDomainEventCreatedAt(LocalDateTime.parse("2026-01-01T12:00:00"));
+ var event11 = CurrentStateUpdateFixtures.paymentUpdate(11L, "captured", Instant.parse("2026-01-01T11:30:00Z"))
+ .setDomainEventCreatedAt(LocalDateTime.parse("2026-01-01T11:00:00"));
+
+ paymentTxnCurrentDao.upsert(event10);
+ paymentTxnCurrentDao.upsert(event11);
+
+ var row = jdbcTemplate.queryForMap(
+ "SELECT domain_event_id, status FROM ccr.payment_txn_current " +
+ "WHERE invoice_id = 'invoice-1' AND payment_id = 'payment-1'"
+ );
+
+ assertThat(row.get("domain_event_id")).isEqualTo(11L);
+ assertThat(row.get("status")).isEqualTo("captured");
+ }
+
+ @Test
+ void withdrawalSessionIsMonotonic() {
+ withdrawalSessionDao.upsert(CurrentStateUpdateFixtures.withdrawalSessionUpdate(
+ "session-1",
+ "withdrawal-1",
+ 20L,
+ Instant.parse("2026-01-01T00:00:00Z"),
+ null
+ ));
+ withdrawalSessionDao.upsert(CurrentStateUpdateFixtures.withdrawalSessionUpdate(
+ "session-1",
+ "withdrawal-stale",
+ 19L,
+ Instant.parse("2025-12-31T23:59:00Z"),
+ null
+ ));
+
+ var row = jdbcTemplate.queryForMap(
+ "SELECT withdrawal_id FROM ccr.withdrawal_session WHERE session_id = 'session-1'"
+ );
+ assertThat(row.get("withdrawal_id")).isEqualTo("withdrawal-1");
+ }
+
+ @Test
+ void withdrawalSessionTrxIdIsSet() {
+ withdrawalSessionDao.upsert(CurrentStateUpdateFixtures.withdrawalSessionUpdate(
+ "session-2",
+ "withdrawal-2",
+ 30L,
+ Instant.parse("2026-01-01T00:00:00Z"),
+ null
+ ));
+ withdrawalSessionDao.upsert(CurrentStateUpdateFixtures.withdrawalSessionUpdate(
+ "session-2",
+ "withdrawal-2",
+ 31L,
+ Instant.parse("2026-01-01T00:01:00Z"),
+ "trx-123"
+ ));
+
+ var row = jdbcTemplate.queryForMap(
+ "SELECT trx_id, trx_search FROM ccr.withdrawal_session WHERE session_id = 'session-2'"
+ );
+ assertThat(row.get("trx_id")).isEqualTo("trx-123");
+ assertThat(row.get("trx_search")).isEqualTo("trx-123");
+ }
+
+ @Test
+ void withdrawalUpsertIsMonotonic() {
+ var finalizedAt = Instant.parse("2026-01-01T11:10:00Z");
+ var laterFinalizedAt = Instant.parse("2026-01-01T11:20:00Z");
+
+ withdrawalTxnCurrentDao.upsert(
+ CurrentStateUpdateFixtures.withdrawalUpdate(30L, "succeeded", finalizedAt)
+ );
+ withdrawalTxnCurrentDao.upsert(
+ CurrentStateUpdateFixtures.withdrawalUpdate(31L, "failed", laterFinalizedAt)
+ );
+ withdrawalTxnCurrentDao.upsert(CurrentStateUpdateFixtures.withdrawalUpdate(29L, "pending", null));
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT domain_event_id, status, finalized_at
+ FROM ccr.withdrawal_txn_current
+ WHERE withdrawal_id = 'withdrawal-1'
+ """
+ );
+
+ assertThat(row.get("domain_event_id")).isEqualTo(31L);
+ assertThat(row.get("status")).isEqualTo("failed");
+ assertThat(((Timestamp) Objects.requireNonNull(row.get("finalized_at"))).toLocalDateTime())
+ .isEqualTo(LocalDateTime.ofInstant(laterFinalizedAt, ZoneOffset.UTC));
+ }
+
+
+ @Test
+ void paymentStatusCorrectionReplacesFinalizationAndClearsError() {
+ var failedAt = Instant.parse("2026-01-01T10:10:00Z");
+ var capturedAt = Instant.parse("2026-01-01T10:20:00Z");
+
+ paymentTxnCurrentDao.upsert(
+ CurrentStateUpdateFixtures.paymentUpdate(40L, "failed", failedAt)
+ .setErrorSummary("declined")
+ );
+ paymentTxnCurrentDao.upsert(
+ CurrentStateUpdateFixtures.paymentUpdate(41L, "captured", capturedAt)
+ .setErrorSummary(null)
+ );
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT status, finalized_at, error_summary
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = 'invoice-1' AND payment_id = 'payment-1'
+ """
+ );
+
+ assertThat(row.get("status")).isEqualTo("captured");
+ assertThat(((Timestamp) Objects.requireNonNull(row.get("finalized_at"))).toLocalDateTime())
+ .isEqualTo(LocalDateTime.ofInstant(capturedAt, ZoneOffset.UTC));
+ assertThat(row.get("error_summary")).isNull();
+ }
+
+ @Test
+ void withdrawalStatusCorrectionReplacesFinalizationAndClearsError() {
+ var failedAt = Instant.parse("2026-01-01T11:10:00Z");
+ var succeededAt = Instant.parse("2026-01-01T11:20:00Z");
+
+ withdrawalTxnCurrentDao.upsert(
+ CurrentStateUpdateFixtures.withdrawalUpdate(50L, "failed", failedAt)
+ .setErrorSummary("declined")
+ );
+ withdrawalTxnCurrentDao.upsert(
+ CurrentStateUpdateFixtures.withdrawalUpdate(51L, "succeeded", succeededAt)
+ .setErrorSummary(null)
+ );
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT status, finalized_at, error_summary
+ FROM ccr.withdrawal_txn_current
+ WHERE withdrawal_id = 'withdrawal-1'
+ """
+ );
+
+ assertThat(row.get("status")).isEqualTo("succeeded");
+ assertThat(((Timestamp) Objects.requireNonNull(row.get("finalized_at"))).toLocalDateTime())
+ .isEqualTo(LocalDateTime.ofInstant(succeededAt, ZoneOffset.UTC));
+ assertThat(row.get("error_summary")).isNull();
+ }
+
+ @Test
+ void displayNameLookupUpsertOverwritesExistingDominantName() {
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.SHOP, "shop-1", "Shop One", 0L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.SHOP, "shop-1", "Shop Uno", 0L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.PROVIDER, "provider-1", "Provider One", 0L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.TERMINAL, "terminal-1", "Terminal One", 0L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.WALLET, "wallet-1", "Wallet One", 0L, false);
+
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT shop_name FROM ccr.shop_lookup WHERE shop_id = 'shop-1'",
+ String.class
+ )).isEqualTo("Shop Uno");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT shop_search FROM ccr.shop_lookup WHERE shop_id = 'shop-1'",
+ String.class
+ )).isEqualTo("shop-1 shop uno");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT provider_name FROM ccr.provider_lookup WHERE provider_id = 'provider-1'",
+ String.class
+ )).isEqualTo("Provider One");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT provider_search FROM ccr.provider_lookup WHERE provider_id = 'provider-1'",
+ String.class
+ )).isEqualTo("provider-1 provider one");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT terminal_name FROM ccr.terminal_lookup WHERE terminal_id = 'terminal-1'",
+ String.class
+ )).isEqualTo("Terminal One");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT terminal_search FROM ccr.terminal_lookup WHERE terminal_id = 'terminal-1'",
+ String.class
+ )).isEqualTo("terminal-1 terminal one");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT wallet_name FROM ccr.wallet_lookup WHERE wallet_id = 'wallet-1'",
+ String.class
+ )).isEqualTo("Wallet One");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT wallet_search FROM ccr.wallet_lookup WHERE wallet_id = 'wallet-1'",
+ String.class
+ )).isEqualTo("wallet-1 wallet one");
+ }
+
+ @Test
+ void displayNameLookupUpsertWithBlankNameStillAdvancesVersion() {
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.SHOP, "shop-blank-name", "Shop One", 10L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.SHOP, "shop-blank-name", null, 11L, false);
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT shop_name, shop_search, dominant_version_id, deleted
+ FROM ccr.shop_lookup
+ WHERE shop_id = 'shop-blank-name'
+ """
+ );
+
+ assertThat(row.get("shop_name")).isNull();
+ assertThat(row.get("shop_search")).isEqualTo("shop-blank-name");
+ assertThat(row.get("dominant_version_id")).isEqualTo(11L);
+ assertThat(row.get("deleted")).isEqualTo(false);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/DominantLookupIngestionIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/DominantLookupIngestionIntegrationTest.java
new file mode 100644
index 0000000..67c39f3
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/DominantLookupIngestionIntegrationTest.java
@@ -0,0 +1,47 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.fixture.DominantCommitFixtures;
+import dev.vality.ccreporter.ingestion.dominant.DominantLookupIngestionService;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DominantLookupIngestionIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Autowired
+ private DominantLookupIngestionService dominantLookupIngestionService;
+
+ @Test
+ void dominantLookupUpdatesAreMonotonicAndTombstonesBlockStaleReinsert() {
+ dominantLookupIngestionService.handleCommits(java.util.List.of(
+ DominantCommitFixtures.insertCommit(10L),
+ DominantCommitFixtures.removeShopCommit(12L),
+ DominantCommitFixtures.updateShopCommit(11L, "Stale Shop"),
+ DominantCommitFixtures.updateProviderCommit(13L, "Provider New")
+ ));
+
+ var shop = jdbcTemplate.queryForMap(
+ """
+ SELECT shop_name, dominant_version_id, deleted
+ FROM ccr.shop_lookup
+ WHERE shop_id = 'shop-lookup'
+ """
+ );
+ var provider = jdbcTemplate.queryForMap(
+ """
+ SELECT provider_name, dominant_version_id, deleted
+ FROM ccr.provider_lookup
+ WHERE provider_id = '1001'
+ """
+ );
+
+ assertThat(shop.get("shop_name")).isNull();
+ assertThat(shop.get("dominant_version_id")).isEqualTo(12L);
+ assertThat(shop.get("deleted")).isEqualTo(true);
+ assertThat(provider.get("provider_name")).isEqualTo("Provider New");
+ assertThat(provider.get("dominant_version_id")).isEqualTo(13L);
+ assertThat(provider.get("deleted")).isEqualTo(false);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/IngestionSerializedEventsIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/IngestionSerializedEventsIntegrationTest.java
new file mode 100644
index 0000000..31814dc
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/IngestionSerializedEventsIntegrationTest.java
@@ -0,0 +1,190 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.fixture.SerializedIngestionEventFixtures;
+import dev.vality.ccreporter.ingestion.payment.PaymentIngestionService;
+import dev.vality.ccreporter.ingestion.withdrawal.WithdrawalIngestionService;
+import dev.vality.ccreporter.ingestion.withdrawal.session.WithdrawalSessionIngestionService;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Проверяет разбор сериализованных событий и то, как ingestion переносит их в current-state таблицы.
+ */
+class IngestionSerializedEventsIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Autowired
+ private PaymentIngestionService paymentIngestionService;
+
+ @Autowired
+ private WithdrawalIngestionService withdrawalIngestionService;
+
+ @Autowired
+ private WithdrawalSessionIngestionService withdrawalSessionIngestionService;
+
+ @Test
+ void paymentEventsAreParsedFromSerializedPayloadAndProjectedIntoCurrentState() {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.paymentEvents());
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT status, provider_id, terminal_id, amount, fee, trx_id, rrn, approval_code, finalized_at,
+ original_amount, original_currency, converted_amount, exchange_rate_internal,
+ provider_amount, provider_currency
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ SerializedIngestionEventFixtures.PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.PAYMENT_ID
+ );
+
+ assertThat(row.get("status")).isEqualTo("captured");
+ assertThat(row.get("provider_id")).isEqualTo("100");
+ assertThat(row.get("terminal_id")).isEqualTo("200");
+ assertThat(row.get("amount")).isEqualTo(1000L);
+ assertThat(row.get("fee")).isEqualTo(10L);
+ assertThat(row.get("trx_id")).isEqualTo("trx-payment-1");
+ assertThat(row.get("rrn")).isEqualTo("rrn-payment-1");
+ assertThat(row.get("approval_code")).isEqualTo("approval-payment-1");
+ assertThat(row.get("finalized_at")).isEqualTo(Timestamp.valueOf(LocalDateTime.parse("2026-01-01T00:04:00")));
+ assertThat(row.get("original_amount")).isEqualTo(1000L);
+ assertThat(row.get("original_currency")).isEqualTo("RUB");
+ assertThat(row.get("converted_amount")).isEqualTo(900L);
+ assertThat(row.get("exchange_rate_internal")).isEqualTo(new java.math.BigDecimal("0.9000000000"));
+ assertThat(row.get("provider_amount")).isEqualTo(900L);
+ assertThat(row.get("provider_currency")).isEqualTo("EUR");
+ }
+
+ @Test
+ void allPaymentChangesFromSingleMachineEventAreApplied() {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.paymentChangesCombinedInSingleEvent());
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT status, amount, fee, trx_id, rrn, approval_code,
+ converted_amount, exchange_rate_internal, provider_amount, provider_currency
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ SerializedIngestionEventFixtures.PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.PAYMENT_ID
+ );
+
+ assertThat(row.get("status")).isEqualTo("captured");
+ assertThat(row.get("amount")).isEqualTo(1000L);
+ assertThat(row.get("fee")).isEqualTo(10L);
+ assertThat(row.get("trx_id")).isEqualTo("trx-payment-1");
+ assertThat(row.get("rrn")).isEqualTo("rrn-payment-1");
+ assertThat(row.get("approval_code")).isEqualTo("approval-payment-1");
+ assertThat(row.get("converted_amount")).isEqualTo(900L);
+ assertThat(row.get("exchange_rate_internal")).isEqualTo(new java.math.BigDecimal("0.9000000000"));
+ assertThat(row.get("provider_amount")).isEqualTo(900L);
+ assertThat(row.get("provider_currency")).isEqualTo("EUR");
+ }
+
+ @Test
+ void paymentProxyStateFallbackPopulatesTrxIdWhenTransactionBoundIsAbsent() {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.paymentProxyStateFallbackEvents());
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT status, trx_id
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ SerializedIngestionEventFixtures.PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.PAYMENT_ID
+ );
+
+ assertThat(row.get("status")).isEqualTo("captured");
+ assertThat(row.get("trx_id")).isEqualTo("trx-from-proxy-state-1");
+ }
+
+ @Test
+ void failedPaymentStatusStoresPackedErrorSummary() {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.failedPaymentEvents());
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT status, error_summary
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ SerializedIngestionEventFixtures.PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.PAYMENT_ID
+ );
+
+ assertThat(row.get("status")).isEqualTo("failed");
+ assertThat(row.get("error_summary"))
+ .isEqualTo("authorization_failed:payment_tool_rejected | 'FAILED: REFUSED' - 'Transaction failed'");
+ }
+
+ @Test
+ void legacyPaymentShapeWithOwnerIdAndShopIdStillProjectsIntoCurrentState() {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.legacyPaymentEvents());
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT party_id, shop_id, status, amount, currency, payment_tool_type
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ SerializedIngestionEventFixtures.LEGACY_PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.LEGACY_PAYMENT_ID
+ );
+
+ assertThat(row.get("party_id")).isEqualTo("test-party-1");
+ assertThat(row.get("shop_id")).isEqualTo("test-shop-1");
+ assertThat(row.get("status")).isEqualTo("failed");
+ assertThat(row.get("amount")).isEqualTo(100000L);
+ assertThat(row.get("currency")).isEqualTo("KZT");
+ assertThat(row.get("payment_tool_type")).isEqualTo("payment_terminal");
+ }
+
+ @Test
+ void withdrawalEventsAreParsedFromSerializedPayloadAndProjectedIntoCurrentState() {
+ withdrawalIngestionService.handleEvents(SerializedIngestionEventFixtures.withdrawalEvents());
+ withdrawalSessionIngestionService.handleEvents(SerializedIngestionEventFixtures.withdrawalSessionEvents());
+
+ var withdrawalRow = jdbcTemplate.queryForMap(
+ """
+ SELECT status, provider_id, terminal_id, amount, fee, wallet_id, finalized_at,
+ original_amount, original_currency, converted_amount,
+ provider_amount, provider_currency
+ FROM ccr.withdrawal_txn_current
+ WHERE withdrawal_id = ?
+ """,
+ SerializedIngestionEventFixtures.WITHDRAWAL_ID
+ );
+
+ assertThat(withdrawalRow.get("status")).isEqualTo("succeeded");
+ assertThat(withdrawalRow.get("provider_id")).isEqualTo("300");
+ assertThat(withdrawalRow.get("terminal_id")).isEqualTo("400");
+ assertThat(withdrawalRow.get("amount")).isEqualTo(1000L);
+ assertThat(withdrawalRow.get("fee")).isEqualTo(20L);
+ assertThat(withdrawalRow.get("wallet_id")).isEqualTo("wallet-serialized");
+ assertThat(withdrawalRow.get("finalized_at"))
+ .isEqualTo(Timestamp.valueOf(LocalDateTime.parse("2026-01-01T00:05:00")));
+ assertThat(withdrawalRow.get("original_amount")).isEqualTo(1200L);
+ assertThat(withdrawalRow.get("original_currency")).isEqualTo("USD");
+ assertThat(withdrawalRow.get("converted_amount")).isEqualTo(1000L);
+ assertThat(withdrawalRow.get("provider_amount")).isEqualTo(1000L);
+ assertThat(withdrawalRow.get("provider_currency")).isEqualTo("RUB");
+
+ var sessionRow = jdbcTemplate.queryForMap(
+ """
+ SELECT trx_id
+ FROM ccr.withdrawal_session
+ WHERE withdrawal_id = ?
+ """,
+ SerializedIngestionEventFixtures.WITHDRAWAL_ID
+ );
+
+ assertThat(sessionRow.get("trx_id")).isEqualTo("trx-withdrawal-1");
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/IngestionToReportLifecycleIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/IngestionToReportLifecycleIntegrationTest.java
new file mode 100644
index 0000000..72ff08e
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/IngestionToReportLifecycleIntegrationTest.java
@@ -0,0 +1,298 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.*;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.fixture.SerializedIngestionEventFixtures;
+import dev.vality.ccreporter.ingestion.payment.PaymentIngestionService;
+import dev.vality.ccreporter.ingestion.withdrawal.WithdrawalIngestionService;
+import dev.vality.ccreporter.ingestion.withdrawal.session.WithdrawalSessionIngestionService;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Сквозной сценарий от ingestion до готового отчёта, чтобы вся цепочка проверялась одним прогоном.
+ */
+class IngestionToReportLifecycleIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Autowired
+ private PaymentIngestionService paymentIngestionService;
+
+ @Autowired
+ private WithdrawalIngestionService withdrawalIngestionService;
+
+ @Autowired
+ private WithdrawalSessionIngestionService withdrawalSessionIngestionService;
+
+ @Test
+ void paymentsReportLifecycleRunsFromIngestionToPresignedUrl() throws Exception {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.paymentEvents());
+
+ var reportId = reportingHandler.createReport(paymentsLifecycleRequest());
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+ var url =
+ reportingHandler.generatePresignedUrl(new GeneratePresignedUrlRequest(report.getFile().getFileId()));
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains(
+ "created_date,created_time,finalized_date,finalized_time,invoice_id,payment_id,status,amount,currency"
+ );
+ assertThat(csv).contains(
+ SerializedIngestionEventFixtures.PAYMENT_INVOICE_ID +
+ "," +
+ SerializedIngestionEventFixtures.PAYMENT_ID +
+ ",captured,10.00,RUB"
+ );
+ assertThat(csv).contains("trx-payment-1");
+ assertThat(url).isEqualTo("https://download.example/" + report.getFile().getFileId());
+ assertThat(reportingHandler.getReports(new GetReportsRequest()).getReports())
+ .extracting(Report::getReportId)
+ .contains(reportId);
+ }
+
+ @Test
+ void realPaymentFixtureRunsThroughIngestionAndReportLifecycle() throws Exception {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.realPaymentEvents());
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT status, provider_id, terminal_id, amount, currency, trx_id, external_id,
+ payment_tool_type, finalized_at, original_amount, original_currency,
+ converted_amount, exchange_rate_internal, provider_amount, provider_currency,
+ error_summary
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ SerializedIngestionEventFixtures.REAL_PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.REAL_PAYMENT_ID
+ );
+ var reportId = reportingHandler.createReport(realPaymentsLifecycleRequest());
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-03-13T00:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(row.get("status")).isEqualTo("captured");
+ assertThat(row.get("provider_id")).isEqualTo("254");
+ assertThat(row.get("terminal_id")).isEqualTo("2551");
+ assertThat(row.get("amount")).isEqualTo(425000L);
+ assertThat(row.get("currency")).isEqualTo("KZT");
+ assertThat(row.get("trx_id")).isEqualTo("test-provider-trx-1");
+ assertThat(row.get("external_id")).isEqualTo("test-external-1");
+ assertThat(row.get("payment_tool_type")).isEqualTo("bank_card");
+ assertThat(row.get("finalized_at")).isEqualTo(Timestamp.valueOf(LocalDateTime.parse("2026-03-12T21:50:39")));
+ assertThat(row.get("original_amount")).isEqualTo(425000L);
+ assertThat(row.get("original_currency")).isEqualTo("KZT");
+ assertThat(row.get("converted_amount")).isEqualTo(748L);
+ assertThat((BigDecimal) row.get("exchange_rate_internal")).isEqualByComparingTo("568.23");
+ assertThat(row.get("provider_amount")).isEqualTo(425000L);
+ assertThat(row.get("provider_currency")).isEqualTo("KZT");
+ assertThat(row.get("error_summary")).isNull();
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains(
+ SerializedIngestionEventFixtures.REAL_PAYMENT_INVOICE_ID +
+ "," +
+ SerializedIngestionEventFixtures.REAL_PAYMENT_ID +
+ ",captured,4250.00,KZT"
+ );
+ assertThat(csv).contains("test-provider-trx-1");
+ }
+
+ @Test
+ void withdrawalsReportLifecycleRunsFromIngestionToPresignedUrl() throws Exception {
+ withdrawalIngestionService.handleEvents(SerializedIngestionEventFixtures.withdrawalEvents());
+ withdrawalSessionIngestionService.handleEvents(SerializedIngestionEventFixtures.withdrawalSessionEvents());
+
+ var reportId = reportingHandler.createReport(withdrawalsLifecycleRequest());
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+ var url =
+ reportingHandler.generatePresignedUrl(new GeneratePresignedUrlRequest(report.getFile().getFileId()));
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains(
+ "created_date,created_time,finalized_date,finalized_time,withdrawal_id,status,amount,currency"
+ );
+ assertThat(csv).contains(SerializedIngestionEventFixtures.WITHDRAWAL_ID + ",succeeded,10.00,RUB");
+ assertThat(csv).contains("trx-withdrawal-1");
+ assertThat(url).isEqualTo("https://download.example/" + report.getFile().getFileId());
+ assertThat(reportingHandler.getReports(new GetReportsRequest()).getReports())
+ .extracting(Report::getReportId)
+ .contains(reportId);
+ }
+
+ @Test
+ void realWithdrawalFixtureRunsThroughIngestionAndReportLifecycle() throws Exception {
+ withdrawalIngestionService.handleEvents(SerializedIngestionEventFixtures.realWithdrawalEvents());
+ withdrawalSessionIngestionService.handleEvents(SerializedIngestionEventFixtures.realWithdrawalSessionEvents());
+
+ var row = jdbcTemplate.queryForMap(
+ """
+ SELECT status, provider_id, terminal_id, amount, fee, currency, wallet_id, external_id,
+ finalized_at
+ FROM ccr.withdrawal_txn_current
+ WHERE withdrawal_id = ?
+ """,
+ SerializedIngestionEventFixtures.REAL_WITHDRAWAL_ID
+ );
+ var sessionCount = jdbcTemplate.queryForObject(
+ "SELECT count(*) FROM ccr.withdrawal_session WHERE withdrawal_id = ?",
+ Integer.class,
+ SerializedIngestionEventFixtures.REAL_WITHDRAWAL_ID
+ );
+ var reportId = reportingHandler.createReport(realWithdrawalsLifecycleRequest());
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-03-13T00:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(row.get("status")).isEqualTo("succeeded");
+ assertThat(row.get("provider_id")).isEqualTo("518");
+ assertThat(row.get("terminal_id")).isEqualTo("2465");
+ assertThat(row.get("amount")).isEqualTo(2900000L);
+ assertThat(row.get("fee")).isEqualTo(145000L);
+ assertThat(row.get("currency")).isEqualTo("RUB");
+ assertThat(row.get("wallet_id")).isEqualTo("3313");
+ assertThat(row.get("external_id")).isEqualTo("test-withdrawal-external-1");
+ assertThat(row.get("finalized_at"))
+ .isEqualTo(Timestamp.valueOf(LocalDateTime.parse("2026-02-17T18:33:00.283712")));
+ assertThat(sessionCount).isEqualTo(2);
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains(
+ SerializedIngestionEventFixtures.REAL_WITHDRAWAL_ID + ",succeeded,29000.00,RUB"
+ );
+ assertThat(csv).contains("518");
+ assertThat(csv).contains("2465");
+ }
+
+ @Test
+ void paymentCollectionFixtureBuildsMultiRowReport() throws Exception {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.paymentCollectionEvents());
+
+ var reportId = reportingHandler.createReport(paymentCollectionLifecycleRequest());
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-03-14T00:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(12L);
+ assertThat(csv).contains("2EfF8NQk30a,1,");
+ assertThat(csv).contains("2Ek6RLXFbyi,1,");
+ assertThat(csv).contains("2El3kaBqBU0,1,");
+ assertThat(csv).contains("2EloA78BbF2,1,");
+ assertThat(csv).contains("2ElsBI5GY4m,1,");
+ assertThat(csv).contains("2EnbPdxImPo,1,");
+ assertThat(csv).contains("test-invoice-1,1,");
+ assertThat(csv).contains("test-invoice-2,1,");
+ assertThat(csv).contains("test-invoice-3,1,");
+ assertThat(csv).contains("test-invoice-4,1,");
+ assertThat(csv).contains("test-invoice-5,1,");
+ assertThat(csv).contains("test-invoice-6,1,");
+ }
+
+ @Test
+ void withdrawalCollectionFixtureBuildsMultiRowReport() throws Exception {
+ withdrawalIngestionService.handleEvents(SerializedIngestionEventFixtures.withdrawalCollectionEvents());
+ withdrawalSessionIngestionService.handleEvents(
+ SerializedIngestionEventFixtures.withdrawalSessionCollectionEvents());
+
+ var reportId = reportingHandler.createReport(withdrawalCollectionLifecycleRequest());
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-03-14T00:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(6L);
+ assertThat(csv).contains("211890,succeeded,");
+ assertThat(csv).contains("257060,succeeded,");
+ assertThat(csv).contains("257072,succeeded,");
+ assertThat(csv).contains("257077,succeeded,");
+ assertThat(csv).contains("257080,succeeded,");
+ assertThat(csv).contains("257085,succeeded,");
+ }
+
+ private dev.vality.ccreporter.CreateReportRequest paymentsLifecycleRequest() {
+ return ReportRequestFixtures.payments("ingestion-payments-lifecycle-1", new TimeRange(
+ "2025-12-31T00:00:00Z",
+ "2026-01-02T00:00:00Z"
+ ));
+ }
+
+ private dev.vality.ccreporter.CreateReportRequest withdrawalsLifecycleRequest() {
+ return ReportRequestFixtures.withdrawals("ingestion-withdrawals-lifecycle-1", new TimeRange(
+ "2025-12-31T00:00:00Z",
+ "2026-01-02T00:00:00Z"
+ ));
+ }
+
+ private dev.vality.ccreporter.CreateReportRequest realPaymentsLifecycleRequest() {
+ return ReportRequestFixtures.payments("ingestion-real-payments-lifecycle-1", new TimeRange(
+ "2026-03-12T00:00:00Z",
+ "2026-03-13T00:00:00Z"
+ ));
+ }
+
+ private dev.vality.ccreporter.CreateReportRequest realWithdrawalsLifecycleRequest() {
+ return ReportRequestFixtures.withdrawals("ingestion-real-withdrawals-lifecycle-1", new TimeRange(
+ "2026-02-17T00:00:00Z",
+ "2026-02-21T00:00:00Z"
+ ));
+ }
+
+ private dev.vality.ccreporter.CreateReportRequest paymentCollectionLifecycleRequest() {
+ return ReportRequestFixtures.payments("ingestion-payments-collection-1", new TimeRange(
+ "2025-11-01T00:00:00Z",
+ "2026-03-14T00:00:00Z"
+ ));
+ }
+
+ private dev.vality.ccreporter.CreateReportRequest withdrawalCollectionLifecycleRequest() {
+ return ReportRequestFixtures.withdrawals("ingestion-withdrawals-collection-1", new TimeRange(
+ "2026-02-17T00:00:00Z",
+ "2026-03-14T00:00:00Z"
+ ));
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/KafkaListenerIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/KafkaListenerIntegrationTest.java
new file mode 100644
index 0000000..e9c6a74
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/KafkaListenerIntegrationTest.java
@@ -0,0 +1,168 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.fixture.DominantCommitFixtures;
+import dev.vality.ccreporter.fixture.SerializedIngestionEventFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import dev.vality.ccreporter.integration.support.KafkaIntegrationTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.kafka.config.KafkaListenerEndpointRegistry;
+import org.springframework.kafka.test.EmbeddedKafkaBroker;
+import org.springframework.kafka.test.context.EmbeddedKafka;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+
+import java.sql.Timestamp;
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Проверяет, что Kafka listeners подхватывают сообщения из тестовых топиков и обновляют current-state таблицы.
+ */
+@EmbeddedKafka(partitions = 1, topics = {
+ "ccr-dominant-test",
+ "ccr-payments-test",
+ "ccr-withdrawals-test",
+ "ccr-withdrawal-sessions-test"
+})
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+@TestPropertySource(properties = {
+ "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}",
+ "spring.kafka.consumer.group-id=ccr-kafka-it",
+ "kafka.topics.dominant.id=ccr-dominant-test",
+ "kafka.topics.dominant.enabled=true",
+ "kafka.topics.payments.id=ccr-payments-test",
+ "kafka.topics.payments.enabled=true",
+ "kafka.topics.withdrawals.id=ccr-withdrawals-test",
+ "kafka.topics.withdrawals.enabled=true",
+ "kafka.topics.withdrawal-sessions.id=ccr-withdrawal-sessions-test",
+ "kafka.topics.withdrawal-sessions.enabled=true"
+})
+class KafkaListenerIntegrationTest extends AbstractReportingIntegrationTest {
+
+ private static final Duration LISTENER_TIMEOUT = Duration.ofSeconds(15);
+
+ @Autowired
+ private EmbeddedKafkaBroker embeddedKafkaBroker;
+
+ @Autowired
+ private KafkaListenerEndpointRegistry kafkaListenerEndpointRegistry;
+
+ @BeforeEach
+ void waitForKafkaListenersAssignment() {
+ KafkaIntegrationTestSupport.waitForAssignments(kafkaListenerEndpointRegistry, embeddedKafkaBroker);
+ }
+
+ @Test
+ void paymentTopicMessageIsConsumedAndPersisted() throws Exception {
+ KafkaIntegrationTestSupport.sendBatch(
+ embeddedKafkaBroker,
+ "ccr-payments-test",
+ SerializedIngestionEventFixtures.paymentEvents()
+ );
+
+ var row = KafkaIntegrationTestSupport.waitForRow(
+ jdbcTemplate,
+ LISTENER_TIMEOUT,
+ """
+ SELECT status, provider_id, terminal_id, trx_id, finalized_at
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ current -> "captured".equals(current.get("status")) && current.get("trx_id") != null,
+ SerializedIngestionEventFixtures.PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.PAYMENT_ID
+ );
+
+ assertThat(row.get("status")).isEqualTo("captured");
+ assertThat(row.get("provider_id")).isEqualTo("100");
+ assertThat(row.get("terminal_id")).isEqualTo("200");
+ assertThat(row.get("trx_id")).isEqualTo("trx-payment-1");
+ assertThat(row.get("finalized_at")).isEqualTo(Timestamp.valueOf(LocalDateTime.parse("2026-01-01T00:04:00")));
+ }
+
+ @Test
+ void dominantTopicMessageIsConsumedAndPersisted() throws Exception {
+ jdbcTemplate.update(
+ """
+ INSERT INTO ccr.shop_lookup (shop_id, shop_name, dominant_version_id, deleted)
+ VALUES (?, ?, ?, ?)
+ """,
+ "shop-lookup",
+ "Lookup Shop",
+ 0L,
+ false
+ );
+ KafkaIntegrationTestSupport.sendDominantBatch(
+ embeddedKafkaBroker,
+ "ccr-dominant-test",
+ java.util.List.of(DominantCommitFixtures.removeShopCommit(1L))
+ );
+
+ var row = KafkaIntegrationTestSupport.waitForRow(
+ jdbcTemplate,
+ LISTENER_TIMEOUT,
+ """
+ SELECT shop_name, dominant_version_id, deleted
+ FROM ccr.shop_lookup
+ WHERE shop_id = ?
+ """,
+ current -> Boolean.TRUE.equals(current.get("deleted")),
+ "shop-lookup"
+ );
+
+ assertThat(row.get("shop_name")).isNull();
+ assertThat(row.get("dominant_version_id")).isEqualTo(1L);
+ assertThat(row.get("deleted")).isEqualTo(true);
+ }
+
+ @Test
+ void withdrawalTopicsMessagesAreConsumedAndPersisted() throws Exception {
+ KafkaIntegrationTestSupport.sendBatch(
+ embeddedKafkaBroker,
+ "ccr-withdrawals-test",
+ SerializedIngestionEventFixtures.withdrawalEvents()
+ );
+ KafkaIntegrationTestSupport.sendBatch(
+ embeddedKafkaBroker,
+ "ccr-withdrawal-sessions-test",
+ SerializedIngestionEventFixtures.withdrawalSessionEvents()
+ );
+
+ var withdrawalRow = KafkaIntegrationTestSupport.waitForRow(
+ jdbcTemplate,
+ LISTENER_TIMEOUT,
+ """
+ SELECT status, provider_id, terminal_id, fee, finalized_at
+ FROM ccr.withdrawal_txn_current
+ WHERE withdrawal_id = ?
+ """,
+ current -> "succeeded".equals(current.get("status")),
+ SerializedIngestionEventFixtures.WITHDRAWAL_ID
+ );
+
+ assertThat(withdrawalRow.get("status")).isEqualTo("succeeded");
+ assertThat(withdrawalRow.get("provider_id")).isEqualTo("300");
+ assertThat(withdrawalRow.get("terminal_id")).isEqualTo("400");
+ assertThat(withdrawalRow.get("fee")).isEqualTo(20L);
+ assertThat(withdrawalRow.get("finalized_at"))
+ .isEqualTo(Timestamp.valueOf(LocalDateTime.parse("2026-01-01T00:05:00")));
+
+ var sessionRow = KafkaIntegrationTestSupport.waitForRow(
+ jdbcTemplate,
+ LISTENER_TIMEOUT,
+ """
+ SELECT trx_id
+ FROM ccr.withdrawal_session
+ WHERE withdrawal_id = ?
+ """,
+ current -> current.get("trx_id") != null,
+ SerializedIngestionEventFixtures.WITHDRAWAL_ID
+ );
+
+ assertThat(sessionRow.get("trx_id")).isEqualTo("trx-withdrawal-1");
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/KafkaListenerRetryIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/KafkaListenerRetryIntegrationTest.java
new file mode 100644
index 0000000..b5c274e
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/KafkaListenerRetryIntegrationTest.java
@@ -0,0 +1,96 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.fixture.SerializedIngestionEventFixtures;
+import dev.vality.ccreporter.ingestion.payment.PaymentIngestionService;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import dev.vality.ccreporter.integration.support.KafkaIntegrationTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.kafka.config.KafkaListenerEndpointRegistry;
+import org.springframework.kafka.test.EmbeddedKafkaBroker;
+import org.springframework.kafka.test.context.EmbeddedKafka;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.doAnswer;
+
+@EmbeddedKafka(partitions = 1, topics = "ccr-payments-retry-test")
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+@TestPropertySource(properties = {
+ "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}",
+ "spring.kafka.consumer.group-id=ccr-kafka-retry-it",
+ "kafka.consumer.error-backoff-interval-ms=100",
+ "kafka.topics.payments.id=ccr-payments-retry-test",
+ "kafka.topics.payments.enabled=true"
+})
+class KafkaListenerRetryIntegrationTest extends AbstractReportingIntegrationTest {
+
+ private static final Duration LISTENER_TIMEOUT = Duration.ofSeconds(15);
+
+ @Autowired
+ private EmbeddedKafkaBroker embeddedKafkaBroker;
+
+ @Autowired
+ private KafkaListenerEndpointRegistry kafkaListenerEndpointRegistry;
+
+ @MockitoSpyBean
+ private PaymentIngestionService paymentIngestionService;
+
+ @BeforeEach
+ void waitForKafkaListenersAssignment() {
+ KafkaIntegrationTestSupport.waitForAssignments(kafkaListenerEndpointRegistry, embeddedKafkaBroker);
+ }
+
+ @Test
+ void failedBatchIsRetriedAndCommittedOnlyAfterSuccessfulProcessing() throws Exception {
+ var attempts = new AtomicInteger();
+ doAnswer(invocation -> {
+ if (attempts.getAndIncrement() == 0) {
+ throw new IllegalStateException("synthetic payment batch failure");
+ }
+ return invocation.callRealMethod();
+ }).when(paymentIngestionService).handleEvents(anyList());
+
+ KafkaIntegrationTestSupport.sendBatch(
+ embeddedKafkaBroker,
+ "ccr-payments-retry-test",
+ SerializedIngestionEventFixtures.paymentEvents()
+ );
+
+ var row = KafkaIntegrationTestSupport.waitForRow(
+ jdbcTemplate,
+ LISTENER_TIMEOUT,
+ """
+ SELECT status, trx_id
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ current -> "captured".equals(current.get("status")) && current.get("trx_id") != null,
+ SerializedIngestionEventFixtures.PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.PAYMENT_ID
+ );
+
+ var rowCount = jdbcTemplate.queryForObject(
+ """
+ SELECT count(*)
+ FROM ccr.payment_txn_current
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ Integer.class,
+ SerializedIngestionEventFixtures.PAYMENT_INVOICE_ID,
+ SerializedIngestionEventFixtures.PAYMENT_ID
+ );
+
+ assertThat(row.get("status")).isEqualTo("captured");
+ assertThat(row.get("trx_id")).isEqualTo("trx-payment-1");
+ assertThat(rowCount).isEqualTo(1);
+ assertThat(attempts.get()).isGreaterThanOrEqualTo(2);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/PresignedUrlIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/PresignedUrlIntegrationTest.java
new file mode 100644
index 0000000..1c98ad2
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/PresignedUrlIntegrationTest.java
@@ -0,0 +1,100 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.GeneratePresignedUrlRequest;
+import dev.vality.ccreporter.fixture.ReportRecordFixtures;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Проверяет выдачу ссылки на скачивание и то, какие параметры сервис отдаёт в file storage.
+ */
+class PresignedUrlIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Test
+ void generatePresignedUrlUsesConfiguredTtlCap() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("url-1"));
+ var beforeCall = Instant.now();
+ ReportRecordFixtures.markReportCreated(
+ jdbcTemplate,
+ reportId,
+ beforeCall.minusSeconds(120),
+ beforeCall.minusSeconds(120),
+ beforeCall.minusSeconds(60),
+ beforeCall.plus(1, ChronoUnit.HOURS),
+ 1L
+ );
+ ReportRecordFixtures.attachCsvFile(jdbcTemplate, reportId, "file-1", beforeCall.minusSeconds(60));
+
+ var request = new GeneratePresignedUrlRequest("file-1");
+ request.setRequestedExpiresAt(beforeCall.plus(2, ChronoUnit.HOURS).toString());
+ var url = reportingHandler.generatePresignedUrl(request);
+
+ assertThat(url).isEqualTo("https://download.example/file-1");
+ assertThat(stubFileStorageClient.getLastFileId()).isEqualTo("file-1");
+ assertThat(stubFileStorageClient.getLastExpiresAt())
+ .isAfter(beforeCall.plus(14, ChronoUnit.MINUTES))
+ .isBeforeOrEqualTo(beforeCall.plus(15, ChronoUnit.MINUTES).plusSeconds(5));
+ }
+
+ @Test
+ void generatePresignedUrlDoesNotOutliveReport() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("url-report-expiry-1"));
+ var now = Instant.now().truncatedTo(ChronoUnit.MICROS);
+ var reportExpiresAt = now.plus(5, ChronoUnit.MINUTES);
+ ReportRecordFixtures.markReportCreated(
+ jdbcTemplate,
+ reportId,
+ now.minusSeconds(120),
+ now.minusSeconds(120),
+ now.minusSeconds(60),
+ reportExpiresAt,
+ 1L
+ );
+ ReportRecordFixtures.attachCsvFile(jdbcTemplate, reportId, "file-report-expiry-1", now.minusSeconds(60));
+
+ var request = new GeneratePresignedUrlRequest("file-report-expiry-1");
+ request.setRequestedExpiresAt(now.plus(1, ChronoUnit.HOURS).toString());
+ reportingHandler.generatePresignedUrl(request);
+
+ assertThat(stubFileStorageClient.getLastExpiresAt())
+ .isAfter(reportExpiresAt.minusSeconds(1))
+ .isBeforeOrEqualTo(reportExpiresAt);
+ }
+
+ @Test
+ void generatePresignedUrlRejectsFileBeforeReportIsCreated() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("url-pending-1"));
+ ReportRecordFixtures.attachCsvFile(jdbcTemplate, reportId, "file-pending-1", Instant.now());
+
+ assertThatThrownBy(() -> reportingHandler.generatePresignedUrl(
+ new GeneratePresignedUrlRequest("file-pending-1")
+ )).isInstanceOf(dev.vality.ccreporter.FileNotFound.class);
+ }
+
+ @Test
+ void generatePresignedUrlRejectsExpiredReportFile() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("url-expired-1"));
+ var now = Instant.now();
+ ReportRecordFixtures.markReportCreated(
+ jdbcTemplate,
+ reportId,
+ now.minus(3, ChronoUnit.HOURS),
+ now.minus(3, ChronoUnit.HOURS),
+ now.minus(2, ChronoUnit.HOURS),
+ now.minus(1, ChronoUnit.HOURS),
+ 1L
+ );
+ ReportRecordFixtures.attachCsvFile(jdbcTemplate, reportId, "file-expired-1", now.minus(2, ChronoUnit.HOURS));
+
+ assertThatThrownBy(() -> reportingHandler.generatePresignedUrl(
+ new GeneratePresignedUrlRequest("file-expired-1")
+ )).isInstanceOf(dev.vality.ccreporter.FileNotFound.class);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/ReportAuditIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/ReportAuditIntegrationTest.java
new file mode 100644
index 0000000..a6c8c9c
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/ReportAuditIntegrationTest.java
@@ -0,0 +1,106 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.CancelReportRequest;
+import dev.vality.ccreporter.GeneratePresignedUrlRequest;
+import dev.vality.ccreporter.fixture.ReportRecordFixtures;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReportAuditIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Test
+ void createReportWritesAuditEventWithTrustedRequestMetadata() throws Exception {
+ bindCallerWithAuditMetadata("user-7");
+
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("audit-create-1"));
+
+ var auditRow = findLatestAudit(reportId, "report_created");
+
+ assertThat(auditRow.get("actor")).isEqualTo("alice@example.com");
+ assertThat(auditRow.get("event_type")).isEqualTo("report_created");
+ assertThat(jsonText(reportId, "report_created", "{userId}")).isEqualTo("user-id-42");
+ assertThat(jsonText(reportId, "report_created", "{username}")).isEqualTo("alice");
+ assertThat(jsonText(reportId, "report_created", "{email}")).isEqualTo("alice@example.com");
+ assertThat(jsonText(reportId, "report_created", "{traceId}"))
+ .isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
+ assertThat(jsonText(reportId, "report_created", "{traceparent}"))
+ .isEqualTo("00-4bf92f3577b34da6a3ce929d0e0e4736-00aa0ba902b70000-01");
+ assertThat(
+ jsonText(reportId, "report_created", "{details,idempotencyKey}")
+ ).isEqualTo("audit-create-1");
+ }
+
+ @Test
+ void cancelReportAndPresignedUrlWriteAuditEvents() throws Exception {
+ bindCallerWithAuditMetadata("user-9");
+ var canceledReportId = reportingHandler.createReport(ReportRequestFixtures.payments("audit-cancel-1"));
+ var downloadableReportId = reportingHandler.createReport(ReportRequestFixtures.payments("audit-url-1"));
+ var now = Instant.now();
+ ReportRecordFixtures.markReportCreated(
+ jdbcTemplate,
+ downloadableReportId,
+ now.minusSeconds(120),
+ now.minusSeconds(120),
+ now.minusSeconds(60),
+ now.plus(1, ChronoUnit.HOURS),
+ 1L
+ );
+ ReportRecordFixtures.attachCsvFile(
+ jdbcTemplate,
+ downloadableReportId,
+ "file-audit-1",
+ now.minusSeconds(60)
+ );
+
+ reportingHandler.cancelReport(new CancelReportRequest(canceledReportId));
+
+ var request = new GeneratePresignedUrlRequest(
+ "file-audit-1"
+ );
+ request.setRequestedExpiresAt(Instant.now().plus(2, ChronoUnit.HOURS).toString());
+ reportingHandler.generatePresignedUrl(request);
+
+ var cancelAudit = findLatestAudit(canceledReportId, "report_canceled");
+ assertThat(cancelAudit.get("actor")).isEqualTo("alice@example.com");
+ assertThat(jsonText(canceledReportId, "report_canceled", "{details,stateChanged}")).isEqualTo("true");
+
+ var presignedAudit = findLatestAudit(downloadableReportId, "presigned_url_generated");
+ assertThat(presignedAudit.get("actor")).isEqualTo("alice@example.com");
+ assertThat(jsonText(downloadableReportId, "presigned_url_generated", "{details,fileId}"))
+ .isEqualTo("file-audit-1");
+ assertThat(jsonText(downloadableReportId, "presigned_url_generated", "{details,requestedExpiresAt}"))
+ .isNotBlank();
+ assertThat(presignedAudit.get("created_at")).isNotNull();
+ }
+
+ private Map findLatestAudit(long reportId, String eventType) {
+ return jdbcTemplate.queryForMap(
+ """
+ SELECT actor, event_type, created_at
+ FROM ccr.report_audit_event
+ WHERE report_id = ? AND event_type = ?
+ ORDER BY created_at DESC, id DESC
+ LIMIT 1
+ """,
+ reportId,
+ eventType
+ );
+ }
+
+ private String jsonText(long reportId, String eventType, String path) {
+ return jdbcTemplate.queryForObject(
+ "SELECT payload_json #>> '" + path + "' FROM ccr.report_audit_event " +
+ "WHERE report_id = ? AND event_type = ? ORDER BY created_at DESC, id DESC LIMIT 1",
+ String.class,
+ reportId,
+ eventType
+ );
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/ReportExecutionIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/ReportExecutionIntegrationTest.java
new file mode 100644
index 0000000..d157b42
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/ReportExecutionIntegrationTest.java
@@ -0,0 +1,198 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.GetReportRequest;
+import dev.vality.ccreporter.ReportStatus;
+import dev.vality.ccreporter.fixture.CurrentStateTableFixtures;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Проверяет, как из current state собирается CSV и как готовый файл публикуется в storage.
+ */
+class ReportExecutionIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Test
+ void paymentsReportIsBuiltAndPublishedEndToEnd() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-1",
+ "payment-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ var request = ReportRequestFixtures.payments("exec-payments-1");
+ request.setTimezone("Asia/Krasnoyarsk");
+ var reportId = reportingHandler.createReport(request);
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csvLines = readCsvLines(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(report.getFile().getFilename()).isEqualTo("payments-report-" + reportId + ".csv");
+ assertThat(report.getFile().getContentType()).isEqualTo("text/csv");
+ assertThat(report.getDataSnapshotFixedAt()).isNotBlank();
+ assertThat(csvLines).containsExactly(
+ "created_date,created_time,finalized_date,finalized_time,invoice_id,payment_id,status," +
+ "amount,currency,trx_id,provider_id,terminal_id,shop_id,exchange_rate_internal," +
+ "provider_amount,provider_currency,original_amount,original_currency,converted_amount",
+ "2026-01-01,17:00:00,2026-01-01,18:00:00,invoice-1,payment-1,captured,10.00,RUB,trx-1," +
+ "provider-1,terminal-1,shop-1,1.1000000000,9.90,EUR,11.00,USD,10.00"
+ );
+ }
+
+ @Test
+ void withdrawalsReportIsBuiltAndPublishedEndToEnd() throws Exception {
+ CurrentStateTableFixtures.insertWithdrawalRow(
+ jdbcTemplate,
+ "withdrawal-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.withdrawals("exec-withdrawals-1"));
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csvLines = readCsvLines(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(report.getFile().getFilename()).isEqualTo("withdrawals-report-" + reportId + ".csv");
+ assertThat(csvLines).containsExactly(
+ "created_date,created_time,finalized_date,finalized_time,withdrawal_id,status,amount,currency," +
+ "trx_id,provider_id,terminal_id,wallet_id,exchange_rate_internal,provider_amount," +
+ "provider_currency,original_amount,original_currency,converted_amount",
+ "2026-01-01,10:00:00,2026-01-01,11:00:00,withdrawal-1,succeeded,20.00,RUB,trx-w-1," +
+ "provider-1,terminal-1,wallet-1,1.0500000000,19.90,EUR,21.00,USD,20.00"
+ );
+ }
+
+ @Test
+ void withdrawalsReportUsesLatestSessionAndDoesNotDuplicateRows() throws Exception {
+ CurrentStateTableFixtures.insertWithdrawalRow(
+ jdbcTemplate,
+ "withdrawal-latest-session-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ CurrentStateTableFixtures.insertWithdrawalSessionRow(
+ jdbcTemplate,
+ "session-withdrawal-latest-session-1-retry",
+ "withdrawal-latest-session-1",
+ 2L,
+ Instant.parse("2026-01-01T10:30:00Z"),
+ "trx-w-2"
+ );
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.withdrawals("exec-withdrawals-latest-1"));
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csvLines = readCsvLines(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csvLines).hasSize(2);
+ assertThat(csvLines.get(1)).contains("withdrawal-latest-session-1,succeeded,20.00,RUB,trx-w-2");
+ assertThat(csvLines.get(1)).doesNotContain("trx-w-1");
+ }
+
+ @Test
+ void failedUploadIsRetriedAndThenMarkedFailedAtAttemptLimit() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-2",
+ "payment-2",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("exec-failure-1"));
+ stubFileStorageClient.setFailUploads(true);
+
+ reportLifecycleService.processNextPendingReport(Instant.now());
+ var pendingAfterRetry = reportingHandler.getReport(new GetReportRequest(reportId));
+ var retryAt = jdbcTemplate.queryForObject(
+ "SELECT next_attempt_at FROM ccr.report_job WHERE id = ?",
+ LocalDateTime.class,
+ reportId
+ ).toInstant(ZoneOffset.UTC);
+
+ reportLifecycleService.processNextPendingReport(retryAt.plusMillis(1));
+ var failedReport = reportingHandler.getReport(new GetReportRequest(reportId));
+
+ assertThat(pendingAfterRetry.getStatus()).isEqualTo(ReportStatus.pending);
+ assertThat(pendingAfterRetry.getError().getCode()).isEqualTo("report_processing_error");
+ assertThat(pendingAfterRetry.isSetFile()).isFalse();
+ assertThat(failedReport.getStatus()).isEqualTo(ReportStatus.failed);
+ assertThat(failedReport.getError().getCode()).isEqualTo("report_processing_error");
+ assertThat(failedReport.isSetFinishedAt()).isTrue();
+ assertThat(failedReport.isSetFile()).isFalse();
+ }
+
+ @Test
+ void staleProcessingReportIsMarkedTimedOut() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("exec-timeout-1"));
+ var startedAt = Instant.parse("2026-01-01T12:00:00Z");
+
+ reportLifecycleDao.claimNextPendingReport(startedAt).orElseThrow();
+ var updated = reportLifecycleService.timeoutStaleProcessingReports(Instant.parse("2026-01-01T12:01:01Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+
+ assertThat(updated).isEqualTo(1);
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.timed_out);
+ assertThat(report.getError().getCode()).isEqualTo("worker_timeout");
+ }
+
+ @Test
+ void createdReportExpiresAfterConfiguredTtl() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-3",
+ "payment-3",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("exec-expire-1"));
+ var claimTime = Instant.parse("2026-01-01T12:00:00Z");
+
+ reportLifecycleService.processNextPendingReport(claimTime);
+ var createdReport = reportingHandler.getReport(new GetReportRequest(reportId));
+ var expiresAt = Instant.parse(createdReport.getExpiresAt());
+ var expired = reportLifecycleService.expireReadyReports(expiresAt.plusSeconds(1));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+
+ assertThat(expired).isEqualTo(1);
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.expired);
+ assertThat(report.getExpiresAt()).isEqualTo(expiresAt.toString());
+ }
+
+ private List readCsvLines(byte[] bytes, java.nio.charset.Charset charset) {
+ return new String(bytes, charset).lines().toList();
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleConcurrencyIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleConcurrencyIntegrationTest.java
new file mode 100644
index 0000000..30a95b1
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleConcurrencyIntegrationTest.java
@@ -0,0 +1,195 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.GetReportRequest;
+import dev.vality.ccreporter.ReportStatus;
+import dev.vality.ccreporter.fixture.CurrentStateTableFixtures;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.*;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Проверяет, что конкурирующие воркеры не разбирают один и тот же отчёт как попало и не ломают статусы.
+ */
+class ReportLifecycleConcurrencyIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Test
+ void lifecycleTickProcessesReportsWithConfiguredConcurrency() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-concurrency-batch-1",
+ "payment-concurrency-batch-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ final var firstReportId = reportingHandler.createReport(ReportRequestFixtures.payments("concurrency-batch-1"));
+ final var secondReportId = reportingHandler.createReport(ReportRequestFixtures.payments("concurrency-batch-2"));
+ var uploadEntered = new CountDownLatch(2);
+ var releaseUpload = new CountDownLatch(1);
+ stubFileStorageClient.blockUploads(uploadEntered, releaseUpload);
+ var schedulerExecutor = Executors.newSingleThreadExecutor();
+ try {
+ var lifecycleTick = schedulerExecutor.submit(reportLifecycleService::runLifecycleTick);
+
+ assertThat(uploadEntered.await(5, TimeUnit.SECONDS)).isTrue();
+ releaseUpload.countDown();
+ lifecycleTick.get(10, TimeUnit.SECONDS);
+ } finally {
+ releaseUpload.countDown();
+ schedulerExecutor.shutdownNow();
+ assertThat(schedulerExecutor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
+ }
+
+ assertCreatedReport(firstReportId, 1L);
+ assertCreatedReport(secondReportId, 1L);
+ }
+
+ @Test
+ void concurrentWorkersDoNotDoubleProcessSinglePendingReport() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-concurrency-1",
+ "payment-concurrency-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ final var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("concurrency-single-1"));
+
+ var uploadEntered = new CountDownLatch(1);
+ var releaseUpload = new CountDownLatch(1);
+ stubFileStorageClient.blockUploads(uploadEntered, releaseUpload);
+
+ var workers = startWorkers(
+ 2,
+ () -> reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"))
+ );
+ List results;
+ try {
+ assertThat(uploadEntered.await(5, TimeUnit.SECONDS)).isTrue();
+ releaseUpload.countDown();
+ results = awaitResults(workers.futures());
+ } finally {
+ workers.shutdown();
+ }
+
+ assertThat(results).containsExactlyInAnyOrder(true, false);
+ assertThat(countRows("SELECT count(*) FROM ccr.report_file WHERE report_id = ?", reportId)).isEqualTo(1);
+ assertThat(readAttempt(reportId)).isEqualTo(1);
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(report.getFile().getFileId()).isNotBlank();
+ }
+
+ @Test
+ void concurrentWorkersClaimDifferentPendingReportsWithoutDuplicates() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-concurrency-2",
+ "payment-concurrency-2",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-concurrency-3",
+ "payment-concurrency-3",
+ Instant.parse("2026-01-01T10:01:00Z"),
+ Instant.parse("2026-01-01T11:01:00Z")
+ );
+ final var firstReportId = reportingHandler.createReport(ReportRequestFixtures.payments("concurrency-multi-1"));
+ final var secondReportId =
+ reportingHandler.createReport(ReportRequestFixtures.payments("concurrency-multi-2"));
+
+ var uploadEntered = new CountDownLatch(2);
+ var releaseUpload = new CountDownLatch(1);
+ stubFileStorageClient.blockUploads(uploadEntered, releaseUpload);
+
+ var workers = startWorkers(
+ 2,
+ () -> reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"))
+ );
+ List results;
+ try {
+ assertThat(uploadEntered.await(5, TimeUnit.SECONDS)).isTrue();
+ releaseUpload.countDown();
+ results = awaitResults(workers.futures());
+ } finally {
+ workers.shutdown();
+ }
+
+ assertThat(results).containsExactlyInAnyOrder(true, true);
+ assertThat(countRows("SELECT count(*) FROM ccr.report_file WHERE report_id IN (?, ?)", firstReportId,
+ secondReportId))
+ .isEqualTo(2);
+ assertThat(readAttempt(firstReportId)).isEqualTo(1);
+ assertThat(readAttempt(secondReportId)).isEqualTo(1);
+ assertCreatedReport(firstReportId);
+ assertCreatedReport(secondReportId);
+ assertThat(countRows("SELECT count(DISTINCT file_id) FROM ccr.report_file WHERE report_id IN (?, ?)",
+ firstReportId, secondReportId))
+ .isEqualTo(2);
+ }
+
+ private ConcurrentWorkers startWorkers(int workers, Callable task) {
+ var executor = Executors.newFixedThreadPool(workers);
+ var startLatch = new CountDownLatch(1);
+ var futures = new ArrayList>();
+ for (int i = 0; i < workers; i++) {
+ futures.add(executor.submit(() -> {
+ assertThat(startLatch.await(5, TimeUnit.SECONDS)).isTrue();
+ return task.call();
+ }));
+ }
+ startLatch.countDown();
+ return new ConcurrentWorkers(executor, futures);
+ }
+
+ private List awaitResults(List> futures)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ var results = new ArrayList(futures.size());
+ for (Future future : futures) {
+ results.add(future.get(10, TimeUnit.SECONDS));
+ }
+ return results;
+ }
+
+ private int countRows(String sql, Object... args) {
+ return Objects.requireNonNull(jdbcTemplate.queryForObject(sql, Integer.class, args));
+ }
+
+ private int readAttempt(long reportId) {
+ return Objects.requireNonNull(jdbcTemplate.queryForObject(
+ "SELECT attempt FROM ccr.report_job WHERE id = ?",
+ Integer.class,
+ reportId
+ ));
+ }
+
+ private void assertCreatedReport(long reportId) throws Exception {
+ assertCreatedReport(reportId, 2L);
+ }
+
+ private void assertCreatedReport(long reportId, long expectedRowsCount) throws Exception {
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(expectedRowsCount);
+ assertThat(report.getFile().getFileId()).isNotBlank();
+ }
+
+ private record ConcurrentWorkers(ExecutorService executor, List> futures) {
+
+ void shutdown() throws InterruptedException {
+ executor.shutdownNow();
+ assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
+ }
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleIntegrationTest.java
new file mode 100644
index 0000000..c1bfbbd
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleIntegrationTest.java
@@ -0,0 +1,188 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.*;
+import dev.vality.ccreporter.fixture.ReportRecordFixtures;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Проверяет обычные переходы отчёта по статусам без фоновой конкуренции.
+ */
+class ReportLifecycleIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Test
+ void reportLifecycleProgressesFromPendingToCreated() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("lifecycle-created-1"));
+ final var startedAt = Instant.parse("2026-01-02T10:00:00Z");
+ final var snapshotFixedAt = Instant.parse("2026-01-02T10:05:00Z");
+ final var finishedAt = Instant.parse("2026-01-02T10:10:00Z");
+ final var expiresAt = Instant.parse("2100-02-01T00:00:00Z");
+
+ var pendingReport = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(pendingReport.getStatus()).isEqualTo(ReportStatus.pending);
+ assertThat(pendingReport.isSetStartedAt()).isFalse();
+ assertThat(pendingReport.isSetFinishedAt()).isFalse();
+ assertThat(pendingReport.isSetFile()).isFalse();
+
+ ReportRecordFixtures.markReportProcessing(jdbcTemplate, reportId, startedAt, snapshotFixedAt);
+
+ var processingReport = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(processingReport.getStatus()).isEqualTo(ReportStatus.processing);
+ assertThat(processingReport.getStartedAt()).isEqualTo(startedAt.toString());
+ assertThat(processingReport.getDataSnapshotFixedAt()).isEqualTo(snapshotFixedAt.toString());
+ assertThat(processingReport.isSetFinishedAt()).isFalse();
+
+ ReportRecordFixtures.markReportCreated(
+ jdbcTemplate,
+ reportId,
+ startedAt,
+ snapshotFixedAt,
+ finishedAt,
+ expiresAt,
+ 42L
+ );
+ ReportRecordFixtures.attachCsvFile(jdbcTemplate, reportId, "file-lifecycle-1", finishedAt);
+
+ var createdReport = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(createdReport.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(createdReport.getStartedAt()).isEqualTo(startedAt.toString());
+ assertThat(createdReport.getDataSnapshotFixedAt()).isEqualTo(snapshotFixedAt.toString());
+ assertThat(createdReport.getFinishedAt()).isEqualTo(finishedAt.toString());
+ assertThat(createdReport.getRowsCount()).isEqualTo(42L);
+ assertThat(createdReport.getExpiresAt()).isEqualTo(expiresAt.toString());
+ assertThat(createdReport.getFile().getFileId()).isEqualTo("file-lifecycle-1");
+ assertThat(createdReport.getFile().getFilename()).isEqualTo("payments.csv");
+
+ var filter = new GetReportsFilter();
+ filter.setStatuses(List.of(ReportStatus.created));
+ var response = reportingHandler.getReports(new GetReportsRequest().setFilter(filter));
+
+ assertThat(response.getReports()).extracting(Report::getReportId).contains(reportId);
+ }
+
+
+ @Test
+ void getReportExpiresOverdueCreatedReportBeforeReturningIt() throws Exception {
+ var now = Instant.now();
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("expired-get-1"));
+
+ ReportRecordFixtures.markReportCreated(
+ jdbcTemplate,
+ reportId,
+ now.minusSeconds(120),
+ now.minusSeconds(90),
+ now.minusSeconds(60),
+ now.minusSeconds(1),
+ 1L
+ );
+ ReportRecordFixtures.attachCsvFile(jdbcTemplate, reportId, "file-expired-get-1", now.minusSeconds(60));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.expired);
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT status::text FROM ccr.report_job WHERE id = ?",
+ String.class,
+ reportId
+ )).isEqualTo("expired");
+ }
+
+ @Test
+ void getReportsExpiresOverdueRowsBeforeApplyingStatusFilter() throws Exception {
+ var now = Instant.now();
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("expired-list-1"));
+
+ ReportRecordFixtures.markReportCreated(
+ jdbcTemplate,
+ reportId,
+ now.minusSeconds(120),
+ now.minusSeconds(90),
+ now.minusSeconds(60),
+ now.minusSeconds(1),
+ 1L
+ );
+ ReportRecordFixtures.attachCsvFile(jdbcTemplate, reportId, "file-expired-list-1", now.minusSeconds(60));
+
+ var createdFilter = new GetReportsFilter().setStatuses(List.of(ReportStatus.created));
+ var createdResponse = reportingHandler.getReports(new GetReportsRequest().setFilter(createdFilter));
+ assertThat(createdResponse.getReports()).extracting(Report::getReportId).doesNotContain(reportId);
+
+ var expiredFilter = new GetReportsFilter().setStatuses(List.of(ReportStatus.expired));
+ var expiredResponse = reportingHandler.getReports(new GetReportsRequest().setFilter(expiredFilter));
+ assertThat(expiredResponse.getReports()).extracting(Report::getReportId).contains(reportId);
+ }
+
+ @Test
+ void processingReportCanBeCanceledAndBecomesTerminal() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("cancel-processing-1"));
+ var startedAt = Instant.parse("2026-01-03T10:00:00Z");
+ var snapshotFixedAt = Instant.parse("2026-01-03T10:02:00Z");
+
+ ReportRecordFixtures.markReportProcessing(jdbcTemplate, reportId, startedAt, snapshotFixedAt);
+
+ reportingHandler.cancelReport(new CancelReportRequest(reportId));
+ reportingHandler.cancelReport(new CancelReportRequest(reportId));
+
+ var canceledReport = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(canceledReport.getStatus()).isEqualTo(ReportStatus.canceled);
+ assertThat(canceledReport.getStartedAt()).isEqualTo(startedAt.toString());
+ assertThat(canceledReport.getDataSnapshotFixedAt()).isEqualTo(snapshotFixedAt.toString());
+ assertThat(canceledReport.getFinishedAt()).isNotBlank();
+ }
+
+ @Test
+ void canceledRetryDoesNotKeepNextAttemptTime() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("cancel-retry-1"));
+ var startedAt = Instant.parse("2026-01-03T11:00:00Z");
+ var retryAt = Instant.parse("2026-01-03T11:05:00Z");
+
+ reportLifecycleDao.claimNextPendingReport(startedAt).orElseThrow();
+ assertThat(reportLifecycleDao.rescheduleForRetry(
+ reportId,
+ retryAt,
+ "temporary_error",
+ "retry later"
+ )).isTrue();
+
+ reportingHandler.cancelReport(new CancelReportRequest(reportId));
+
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT next_attempt_at IS NULL FROM ccr.report_job WHERE id = ?",
+ Boolean.class,
+ reportId
+ )).isTrue();
+ }
+
+ @Test
+ void failedReportRemainsFailedWhenCancelIsCalled() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("failed-1"));
+ var startedAt = Instant.parse("2026-01-04T10:00:00Z");
+ var snapshotFixedAt = Instant.parse("2026-01-04T10:03:00Z");
+ var finishedAt = Instant.parse("2026-01-04T10:07:00Z");
+
+ ReportRecordFixtures.markReportFailed(
+ jdbcTemplate,
+ reportId,
+ startedAt,
+ snapshotFixedAt,
+ finishedAt,
+ "storage_error",
+ "upload failed"
+ );
+
+ reportingHandler.cancelReport(new CancelReportRequest(reportId));
+
+ var failedReport = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(failedReport.getStatus()).isEqualTo(ReportStatus.failed);
+ assertThat(failedReport.getFinishedAt()).isEqualTo(finishedAt.toString());
+ assertThat(failedReport.getError().getCode()).isEqualTo("storage_error");
+ assertThat(failedReport.getError().getMessage()).isEqualTo("upload failed");
+ assertThat(failedReport.isSetFile()).isFalse();
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleWorkerIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleWorkerIntegrationTest.java
new file mode 100644
index 0000000..d563ec2
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/ReportLifecycleWorkerIntegrationTest.java
@@ -0,0 +1,161 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.GetReportRequest;
+import dev.vality.ccreporter.ReportStatus;
+import dev.vality.ccreporter.domain.tables.pojos.ReportFile;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Проверяет работу фонового воркера, который подбирает pending-отчёты и доводит их до финального состояния.
+ */
+class ReportLifecycleWorkerIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Test
+ void claimPicksOldestDuePendingReportAndMarksItProcessing() throws Exception {
+ var firstReportId = reportingHandler.createReport(ReportRequestFixtures.payments("claim-order-1"));
+ final var secondReportId = reportingHandler.createReport(ReportRequestFixtures.payments("claim-order-2"));
+ var claimTime = Instant.parse("2026-01-06T10:00:00Z");
+
+ var claimedReport = reportLifecycleDao.claimNextPendingReport(claimTime);
+
+ assertThat(claimedReport).isPresent();
+ assertThat(claimedReport.get().id()).isEqualTo(firstReportId);
+ assertThat(claimedReport.get().attempt()).isEqualTo(1);
+ assertReportStatus(firstReportId, ReportStatus.processing);
+ assertThat(readInstant("SELECT started_at FROM ccr.report_job WHERE id = ?", firstReportId)).isEqualTo(
+ claimTime);
+ assertThat(
+ readNullableInstant("SELECT next_attempt_at FROM ccr.report_job WHERE id = ?", firstReportId)).isNull();
+ assertReportStatus(secondReportId, ReportStatus.pending);
+ }
+
+ @Test
+ void lifecycleTickDrainsAllReadyReports() throws Exception {
+ var firstReportId = reportingHandler.createReport(ReportRequestFixtures.payments("tick-drain-1"));
+ var secondReportId = reportingHandler.createReport(ReportRequestFixtures.payments("tick-drain-2"));
+
+ reportLifecycleService.runLifecycleTick();
+
+ assertThat(reportingHandler.getReport(new GetReportRequest(firstReportId)).getStatus())
+ .isEqualTo(ReportStatus.created);
+ assertThat(reportingHandler.getReport(new GetReportRequest(secondReportId)).getStatus())
+ .isEqualTo(ReportStatus.created);
+ }
+
+ @Test
+ void rescheduleMakesReportClaimableAgainWhenRetryTimeArrives() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("retry-1"));
+ var firstClaimTime = Instant.parse("2026-01-06T11:00:00Z");
+ var retryAt = Instant.parse("2026-01-06T11:05:00Z");
+ var secondClaimTime = Instant.parse("2026-01-06T11:06:00Z");
+
+ var firstClaim = reportLifecycleDao.claimNextPendingReport(firstClaimTime).orElseThrow();
+ var rescheduled = reportLifecycleDao.rescheduleForRetry(
+ reportId,
+ retryAt,
+ "storage_unavailable",
+ "temporary upload issue"
+ );
+ var prematureClaim = reportLifecycleDao.claimNextPendingReport(firstClaimTime.plusSeconds(30));
+ var secondClaim = reportLifecycleDao.claimNextPendingReport(secondClaimTime).orElseThrow();
+
+ assertThat(firstClaim.id()).isEqualTo(reportId);
+ assertThat(rescheduled).isTrue();
+ assertThat(prematureClaim).isEmpty();
+ assertThat(secondClaim.id()).isEqualTo(reportId);
+ assertThat(secondClaim.attempt()).isEqualTo(2);
+
+ var retriedReport = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(retriedReport.getStatus()).isEqualTo(ReportStatus.processing);
+ assertThat(retriedReport.isSetError()).isFalse();
+ assertThat(retriedReport.getStartedAt()).isEqualTo(secondClaimTime.toString());
+ }
+
+ @Test
+ void terminalTransitionBlocksLaterTimeoutRewrite() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("terminal-1"));
+ var claimTime = Instant.parse("2026-01-06T12:00:00Z");
+ var failedAt = Instant.parse("2026-01-06T12:02:00Z");
+ var timedOutAt = Instant.parse("2026-01-06T12:03:00Z");
+
+ reportLifecycleDao.claimNextPendingReport(claimTime).orElseThrow();
+ var failed = reportLifecycleDao.markFailed(reportId, failedAt, "storage_error", "upload failed");
+ var timedOut = reportLifecycleDao.timeoutStaleProcessingReports(timedOutAt, timedOutAt);
+
+ assertThat(failed).isTrue();
+ assertThat(timedOut).isZero();
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.failed);
+ assertThat(report.getFinishedAt()).isEqualTo(failedAt.toString());
+ assertThat(report.isSetDataSnapshotFixedAt()).isFalse();
+ assertThat(report.getError().getCode()).isEqualTo("storage_error");
+ assertThat(report.getError().getMessage()).isEqualTo("upload failed");
+ }
+
+ @Test
+ void createdReportCanBeExpiredWithoutChangingFinishedAt() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("expire-1"));
+ var claimTime = Instant.parse("2026-01-06T13:00:00Z");
+ var snapshotFixedAt = Instant.parse("2026-01-06T13:01:00Z");
+ var createdAt = Instant.parse("2026-01-06T13:02:00Z");
+ var expiresAt = Instant.parse("2026-02-06T00:00:00Z");
+ var expiredAt = Instant.parse("2026-02-06T00:05:00Z");
+ var reportFile = new ReportFile()
+ .setFileId("file-expire-1")
+ .setFileType(dev.vality.ccreporter.domain.enums.FileType.csv)
+ .setFilename("payments.csv")
+ .setContentType("text/csv")
+ .setSizeBytes(128L)
+ .setMd5("md5-value")
+ .setSha256("sha256-value");
+
+ reportLifecycleDao.claimNextPendingReport(claimTime).orElseThrow();
+ var created = reportLifecycleDao.completeReport(
+ reportId,
+ reportFile,
+ snapshotFixedAt,
+ createdAt,
+ expiresAt,
+ 7L
+ );
+ var expired = reportLifecycleDao.expireReports(expiredAt);
+
+ assertThat(created).isTrue();
+ assertThat(expired).isEqualTo(1);
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.expired);
+ assertThat(report.getFinishedAt()).isEqualTo(createdAt.toString());
+ assertThat(report.getExpiresAt()).isEqualTo(expiresAt.toString());
+ assertThat(report.getRowsCount()).isEqualTo(7L);
+ assertThat(report.getFile().getFileId()).isEqualTo("file-expire-1");
+ }
+
+ private void assertReportStatus(long reportId, ReportStatus expectedStatus) {
+ var status = jdbcTemplate.queryForObject(
+ "SELECT status::text FROM ccr.report_job WHERE id = ?",
+ String.class,
+ reportId
+ );
+ assertThat(status).isEqualTo(expectedStatus.name());
+ }
+
+ private Instant readInstant(String sql, long reportId) {
+ var timestamp = jdbcTemplate.queryForObject(sql, LocalDateTime.class, reportId);
+ return timestamp.toInstant(ZoneOffset.UTC);
+ }
+
+ private Instant readNullableInstant(String sql, long reportId) {
+ var timestamp = jdbcTemplate.queryForObject(sql, LocalDateTime.class, reportId);
+ return timestamp == null ? null : timestamp.toInstant(ZoneOffset.UTC);
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/ReportQueryFilteringIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/ReportQueryFilteringIntegrationTest.java
new file mode 100644
index 0000000..edeec41
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/ReportQueryFilteringIntegrationTest.java
@@ -0,0 +1,396 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.GetReportRequest;
+import dev.vality.ccreporter.PaymentsSearchFilter;
+import dev.vality.ccreporter.ReportStatus;
+import dev.vality.ccreporter.WithdrawalsSearchFilter;
+import dev.vality.ccreporter.dao.DominantLookupDao;
+import dev.vality.ccreporter.fixture.CurrentStateTableFixtures;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.fixture.SerializedIngestionEventFixtures;
+import dev.vality.ccreporter.ingestion.payment.PaymentIngestionService;
+import dev.vality.ccreporter.ingestion.withdrawal.WithdrawalIngestionService;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Проверяет, что фильтры в запросе на отчёт реально меняют выборку, а не остаются декоративными полями.
+ */
+class ReportQueryFilteringIntegrationTest extends AbstractReportingIntegrationTest {
+
+ @Autowired
+ private PaymentIngestionService paymentIngestionService;
+
+ @Autowired
+ private WithdrawalIngestionService withdrawalIngestionService;
+
+ @Autowired
+ private DominantLookupDao dominantLookupDao;
+
+ @Test
+ void paymentsQueryFiltersExcludeNonMatchingRows() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-filter-1",
+ "payment-filter-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-filter-2",
+ "payment-filter-2",
+ Instant.parse("2026-01-01T10:01:00Z"),
+ Instant.parse("2026-01-01T11:01:00Z")
+ );
+ jdbcTemplate.update(
+ """
+ UPDATE ccr.payment_txn_current
+ SET shop_id = ?, trx_id = ?, trx_search = ?
+ WHERE invoice_id = ? AND payment_id = ?
+ """,
+ "shop-2",
+ "trx-2",
+ "trx-2",
+ "invoice-filter-2",
+ "payment-filter-2"
+ );
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.SHOP, "shop-1", "Shop One", 0L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.SHOP, "shop-2", "Other Shop", 0L, false);
+
+ var request = ReportRequestFixtures.payments("payments-filter-1");
+ request.getQuery().getPayments().setShopIds(List.of("shop-1"));
+ var filter = new PaymentsSearchFilter();
+ filter.setShopTerm("shop one");
+ filter.setTrxTerm("trx-1");
+ request.getQuery().getPayments().setFilter(filter);
+ var reportId = reportingHandler.createReport(request);
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains("invoice-filter-1,payment-filter-1,captured,10.00,RUB,trx-1");
+ assertThat(csv).doesNotContain("invoice-filter-2,payment-filter-2,captured,10.00,RUB,trx-2");
+ }
+
+ @Test
+ void paymentsSearchTreatsSqlWildcardsAsLiteralCharacters() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-percent-1",
+ "payment-percent-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-percent-2",
+ "payment-percent-2",
+ Instant.parse("2026-01-01T10:01:00Z"),
+ Instant.parse("2026-01-01T11:01:00Z")
+ );
+ jdbcTemplate.update(
+ "UPDATE ccr.payment_txn_current SET shop_id = 'shop-percent-2' " +
+ "WHERE invoice_id = 'invoice-percent-2'"
+ );
+ dominantLookupDao.upsert(
+ DominantLookupDao.LookupType.SHOP,
+ "shop-1",
+ "Growth 100% Shop",
+ 1L,
+ false
+ );
+ dominantLookupDao.upsert(
+ DominantLookupDao.LookupType.SHOP,
+ "shop-percent-2",
+ "Growth 1000 Shop",
+ 1L,
+ false
+ );
+
+ var request = ReportRequestFixtures.payments("payments-literal-wildcard-1");
+ var filter = new PaymentsSearchFilter();
+ filter.setShopTerm("100%");
+ request.getQuery().getPayments().setFilter(filter);
+ var reportId = reportingHandler.createReport(request);
+
+ reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains("invoice-percent-1,payment-percent-1");
+ assertThat(csv).doesNotContain("invoice-percent-2,payment-percent-2");
+ }
+
+ @Test
+ void withdrawalsQueryFiltersExcludeNonMatchingRows() throws Exception {
+ CurrentStateTableFixtures.insertWithdrawalRow(
+ jdbcTemplate,
+ "withdrawal-filter-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ CurrentStateTableFixtures.insertWithdrawalRow(
+ jdbcTemplate,
+ "withdrawal-filter-2",
+ Instant.parse("2026-01-01T10:01:00Z"),
+ Instant.parse("2026-01-01T11:01:00Z")
+ );
+ jdbcTemplate.update(
+ """
+ UPDATE ccr.withdrawal_txn_current
+ SET wallet_id = ?
+ WHERE withdrawal_id = ?
+ """,
+ "wallet-2",
+ "withdrawal-filter-2"
+ );
+ jdbcTemplate.update(
+ """
+ UPDATE ccr.withdrawal_session
+ SET trx_id = ?, trx_search = ?
+ WHERE withdrawal_id = ?
+ """,
+ "trx-w-2",
+ "trx-w-2",
+ "withdrawal-filter-2"
+ );
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.WALLET, "wallet-1", "Wallet One", 0L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.WALLET, "wallet-2", "Other Wallet", 0L, false);
+
+ var request = ReportRequestFixtures.withdrawals("withdrawals-filter-1");
+ request.getQuery().getWithdrawals().setWalletIds(List.of("wallet-1"));
+ var filter = new WithdrawalsSearchFilter();
+ filter.setWalletTerm("wallet one");
+ filter.setTrxTerm("trx-w-1");
+ request.getQuery().getWithdrawals().setFilter(filter);
+ var reportId = reportingHandler.createReport(request);
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains("withdrawal-filter-1,succeeded,20.00,RUB,trx-w-1");
+ assertThat(csv).doesNotContain("withdrawal-filter-2,succeeded,20.00,RUB,trx-w-2");
+ }
+
+ @Test
+ void withdrawalsQueryUsesLatestSessionForTrxFilters() throws Exception {
+ CurrentStateTableFixtures.insertWithdrawalRow(
+ jdbcTemplate,
+ "withdrawal-filter-latest-session-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ CurrentStateTableFixtures.insertWithdrawalSessionRow(
+ jdbcTemplate,
+ "session-withdrawal-filter-latest-session-1-retry",
+ "withdrawal-filter-latest-session-1",
+ 2L,
+ Instant.parse("2026-01-01T10:30:00Z"),
+ "trx-w-2"
+ );
+
+ var request = ReportRequestFixtures.withdrawals("withdrawals-filter-latest-session-1");
+ var filter = new WithdrawalsSearchFilter();
+ filter.setTrxTerm("trx-w-1");
+ request.getQuery().getWithdrawals().setFilter(filter);
+ var reportId = reportingHandler.createReport(request);
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(0L);
+ assertThat(csv.lines().count()).isEqualTo(1L);
+
+ request = ReportRequestFixtures.withdrawals("withdrawals-filter-latest-session-2");
+ filter = new WithdrawalsSearchFilter();
+ filter.setTrxTerm("trx-w-2");
+ request.getQuery().getWithdrawals().setFilter(filter);
+ reportId = reportingHandler.createReport(request);
+
+ processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:01:00Z"));
+
+ report = reportingHandler.getReport(new GetReportRequest(reportId));
+ csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains("withdrawal-filter-latest-session-1,succeeded,20.00,RUB,trx-w-2");
+ assertThat(csv).doesNotContain("trx-w-1");
+ }
+
+ @Test
+ void paymentsNameFiltersUseLocalLookupWhenCurrentStateNamesAreMissing() throws Exception {
+ CurrentStateTableFixtures.insertPaymentRow(
+ jdbcTemplate,
+ "invoice-lookup-1",
+ "payment-lookup-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.SHOP, "shop-1", "Lookup Shop", 0L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.PROVIDER, "provider-1", "Lookup Provider", 0L,
+ false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.TERMINAL, "terminal-1", "Lookup Terminal", 0L,
+ false);
+
+ final var request = ReportRequestFixtures.payments("payments-lookup-1");
+ var filter = new PaymentsSearchFilter();
+ filter.setShopTerm("lookup shop");
+ filter.setProviderTerm("lookup provider");
+ filter.setTerminalTerm("lookup terminal");
+ request.getQuery().getPayments().setFilter(filter);
+ var reportId = reportingHandler.createReport(request);
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains("invoice-lookup-1,payment-lookup-1");
+ }
+
+ @Test
+ void withdrawalsNameFiltersUseLocalLookupWhenCurrentStateNamesAreMissing() throws Exception {
+ CurrentStateTableFixtures.insertWithdrawalRow(
+ jdbcTemplate,
+ "withdrawal-lookup-1",
+ Instant.parse("2026-01-01T10:00:00Z"),
+ Instant.parse("2026-01-01T11:00:00Z")
+ );
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.WALLET, "wallet-1", "Lookup Wallet", 0L, false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.PROVIDER, "provider-1", "Lookup Provider", 0L,
+ false);
+ dominantLookupDao.upsert(DominantLookupDao.LookupType.TERMINAL, "terminal-1", "Lookup Terminal", 0L,
+ false);
+
+ final var request = ReportRequestFixtures.withdrawals("withdrawals-lookup-1");
+ var filter = new WithdrawalsSearchFilter();
+ filter.setWalletTerm("lookup wallet");
+ filter.setProviderTerm("lookup provider");
+ filter.setTerminalTerm("lookup terminal");
+ request.getQuery().getWithdrawals().setFilter(filter);
+ var reportId = reportingHandler.createReport(request);
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-01-01T12:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains("withdrawal-lookup-1,succeeded,20.00,RUB");
+ }
+
+ @Test
+ void paymentCollectionFiltersCanNarrowLargeFixtureSetToSingleRow() throws Exception {
+ paymentIngestionService.handleEvents(SerializedIngestionEventFixtures.paymentCollectionEvents());
+
+ var request = ReportRequestFixtures.payments(
+ "payments-collection-filter-1",
+ ReportRequestFixtures.timeRange("2025-11-01T00:00:00Z", "2026-03-14T00:00:00Z")
+ );
+ request.getQuery().getPayments().setPartyIds(List.of("test-party-1"));
+ request.getQuery().getPayments().setProviderIds(List.of("254"));
+ request.getQuery().getPayments().setTerminalIds(List.of("2551"));
+ request.getQuery().getPayments().setTrxIds(List.of("test-provider-trx-1"));
+ request.getQuery().getPayments().setStatuses(List.of("captured"));
+ var filter = new PaymentsSearchFilter();
+ filter.setTrxTerm("test-provider-trx-1");
+ request.getQuery().getPayments().setFilter(filter);
+ var reportId = reportingHandler.createReport(request);
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-03-14T00:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains("2EnbPdxImPo,1,captured");
+ assertThat(csv).doesNotContain("2EfF8NQk30a,1,");
+ assertThat(csv).doesNotContain("test-invoice-1,1,");
+ }
+
+ @Test
+ void withdrawalCollectionFiltersCanNarrowLargeFixtureSetToSingleRow() throws Exception {
+ withdrawalIngestionService.handleEvents(SerializedIngestionEventFixtures.withdrawalCollectionEvents());
+
+ var request = ReportRequestFixtures.withdrawals(
+ "withdrawals-collection-filter-1",
+ ReportRequestFixtures.timeRange("2026-02-17T00:00:00Z", "2026-03-14T00:00:00Z")
+ );
+ request.getQuery().getWithdrawals().setWalletIds(List.of("3313"));
+ request.getQuery().getWithdrawals().setProviderIds(List.of("518"));
+ request.getQuery().getWithdrawals().setTerminalIds(List.of("2465"));
+ request.getQuery().getWithdrawals().setStatuses(List.of("succeeded"));
+ var reportId = reportingHandler.createReport(request);
+
+ var processed = reportLifecycleService.processNextPendingReport(Instant.parse("2026-03-14T00:00:00Z"));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ var csv = new String(
+ stubFileStorageClient.getStoredContent(report.getFile().getFileId()),
+ StandardCharsets.UTF_8
+ );
+
+ assertThat(processed).isTrue();
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.created);
+ assertThat(report.getRowsCount()).isEqualTo(1L);
+ assertThat(csv).contains("211890,succeeded,29000.00,RUB");
+ assertThat(csv).doesNotContain("257060,succeeded,");
+ assertThat(csv).doesNotContain("257085,succeeded,");
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/ReportingApiSmokeTest.java b/src/test/java/dev/vality/ccreporter/integration/ReportingApiSmokeTest.java
new file mode 100644
index 0000000..c11bb89
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/ReportingApiSmokeTest.java
@@ -0,0 +1,125 @@
+package dev.vality.ccreporter.integration;
+
+import dev.vality.ccreporter.*;
+import dev.vality.ccreporter.fixture.ReportRequestFixtures;
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Короткая проверка базового контракта API: сервис стартует, создаёт отчёт и умеет его читать обратно.
+ */
+class ReportingApiSmokeTest extends AbstractReportingIntegrationTest {
+
+ @Test
+ void applicationStartsWithDatabase() {
+ var result = jdbcTemplate.queryForObject("select 1", Integer.class);
+ var flywayHistoryTableCount = jdbcTemplate.queryForObject(
+ "select count(*) from information_schema.tables where table_schema = ? and table_name = ?",
+ Integer.class,
+ "ccr",
+ "flyway_schema_history"
+ );
+ var reportJobTableCount = jdbcTemplate.queryForObject(
+ "select count(*) from information_schema.tables where table_schema = ? and table_name = ?",
+ Integer.class,
+ "ccr",
+ "report_job"
+ );
+
+ assertThat(result).isEqualTo(1);
+ assertThat(flywayHistoryTableCount).isEqualTo(1);
+ assertThat(reportJobTableCount).isEqualTo(1);
+ }
+
+ @Test
+ void reportStorageDoesNotKeepDerivedColumns() {
+ var reportJobColumns = jdbcTemplate.queryForList(
+ """
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = 'ccr' AND table_name = 'report_job'
+ """,
+ String.class
+ );
+ var reportFileColumns = jdbcTemplate.queryForList(
+ """
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = 'ccr' AND table_name = 'report_file'
+ """,
+ String.class
+ );
+
+ assertThat(reportJobColumns).doesNotContain(
+ "query_hash",
+ "requested_time_from",
+ "requested_time_to",
+ "updated_at"
+ );
+ assertThat(reportFileColumns).doesNotContain("bucket", "object_key");
+ }
+
+ @Test
+ void createReportIsIdempotentAndReadable() throws Exception {
+ var request = ReportRequestFixtures.payments("idem-1");
+
+ var firstId = reportingHandler.createReport(request);
+ var secondId = reportingHandler.createReport(request);
+ var report = reportingHandler.getReport(new GetReportRequest(firstId));
+
+ assertThat(secondId).isEqualTo(firstId);
+ assertThat(report.getReportId()).isEqualTo(firstId);
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.pending);
+ assertThat(report.getReportType()).isEqualTo(ReportType.payments);
+ assertThat(report.getQuery().isSetPayments()).isTrue();
+ }
+
+ @Test
+ void getReportsReturnsContinuationToken() throws Exception {
+ reportingHandler.createReport(ReportRequestFixtures.payments("page-1"));
+ reportingHandler.createReport(ReportRequestFixtures.payments("page-2"));
+
+ var meta = new GetReportsMeta();
+ meta.setLimit(1);
+ var response = reportingHandler.getReports(new GetReportsRequest().setMeta(meta));
+
+ assertThat(response.getReports()).hasSize(1);
+ assertThat(response.getContinuationToken()).isNotBlank();
+ }
+
+ @Test
+ void getReportsOmitsContinuationTokenWhenPageHasNoMoreRows() throws Exception {
+ reportingHandler.createReport(ReportRequestFixtures.payments("last-page-1"));
+
+ var meta = new GetReportsMeta();
+ meta.setLimit(1);
+ var response = reportingHandler.getReports(new GetReportsRequest().setMeta(meta));
+
+ assertThat(response.getReports()).hasSize(1);
+ assertThat(response.isSetContinuationToken()).isFalse();
+ }
+
+ @Test
+ void getReportsValidatesEachCreationTimestampIndependently() {
+ var filter = new GetReportsFilter();
+ filter.setCreatedFrom("not-an-instant");
+
+ assertThatThrownBy(() -> reportingHandler.getReports(new GetReportsRequest().setFilter(filter)))
+ .isInstanceOf(InvalidRequest.class);
+ }
+
+ @Test
+ void cancelReportIsIdempotentForPendingReport() throws Exception {
+ var reportId = reportingHandler.createReport(ReportRequestFixtures.payments("cancel-1"));
+
+ reportingHandler.cancelReport(new CancelReportRequest(reportId));
+ reportingHandler.cancelReport(new CancelReportRequest(reportId));
+
+ var report = reportingHandler.getReport(new GetReportRequest(reportId));
+ assertThat(report.getStatus()).isEqualTo(ReportStatus.canceled);
+ assertThat(report.getFinishedAt()).isNotBlank();
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/base/AbstractReportingIntegrationTest.java b/src/test/java/dev/vality/ccreporter/integration/base/AbstractReportingIntegrationTest.java
new file mode 100644
index 0000000..777d3e4
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/base/AbstractReportingIntegrationTest.java
@@ -0,0 +1,259 @@
+package dev.vality.ccreporter.integration.base;
+
+import dev.vality.ccreporter.ReportingSrv;
+import dev.vality.ccreporter.dao.ReportLifecycleDao;
+import dev.vality.ccreporter.integration.config.ReportingIntegrationTestConfig;
+import dev.vality.ccreporter.report.ReportLifecycleService;
+import dev.vality.ccreporter.storage.FileStorageService;
+import dev.vality.woody.api.trace.TraceData;
+import dev.vality.woody.api.trace.context.TraceContext;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanContext;
+import io.opentelemetry.api.trace.TraceFlags;
+import io.opentelemetry.api.trace.TraceState;
+import io.opentelemetry.context.Scope;
+import io.zonky.test.db.postgres.embedded.EmbeddedPostgres;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.util.StringUtils;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Общая test-среда для integration-сценариев: база, Spring-контекст, caller в request scope и заглушка file storage.
+ */
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = {
+ "server.port=0",
+ "management.server.port=0",
+ "storage.file-storage.url=http://localhost:8022/file-storage",
+ "report.max-attempts=2",
+ "report.worker-concurrency=2",
+ "report.expiration-sec=600",
+ "report.processing-timeout-ms=60000"
+ }
+)
+@Import(ReportingIntegrationTestConfig.class)
+public abstract class AbstractReportingIntegrationTest {
+
+ private Scope requestTraceScope;
+
+ private static final EmbeddedPostgres EMBEDDED_POSTGRES = startPostgres();
+ private static final String JDBC_URL = EMBEDDED_POSTGRES.getJdbcUrl("postgres", "postgres");
+
+ @Autowired
+ protected JdbcTemplate jdbcTemplate;
+
+ @Autowired
+ protected ReportingSrv.Iface reportingHandler;
+
+ @Autowired
+ protected StubFileStorageService stubFileStorageClient;
+
+ @Autowired
+ protected ReportLifecycleDao reportLifecycleDao;
+
+ @Autowired
+ protected ReportLifecycleService reportLifecycleService;
+
+ @DynamicPropertySource
+ static void registerDatasourceProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", () -> JDBC_URL);
+ registry.add("spring.datasource.username", () -> "postgres");
+ registry.add("spring.datasource.password", () -> "");
+ }
+
+ @BeforeEach
+ void setUpBaseState() {
+ jdbcTemplate.update("DELETE FROM ccr.report_audit_event");
+ jdbcTemplate.update("DELETE FROM ccr.report_file");
+ jdbcTemplate.update("DELETE FROM ccr.report_job");
+ jdbcTemplate.update("DELETE FROM ccr.payment_txn_current");
+ jdbcTemplate.update("DELETE FROM ccr.withdrawal_txn_current");
+ jdbcTemplate.update("DELETE FROM ccr.withdrawal_session");
+ jdbcTemplate.update("DELETE FROM ccr.shop_lookup");
+ jdbcTemplate.update("DELETE FROM ccr.provider_lookup");
+ jdbcTemplate.update("DELETE FROM ccr.terminal_lookup");
+ jdbcTemplate.update("DELETE FROM ccr.wallet_lookup");
+ stubFileStorageClient.reset();
+ bindCaller("user-1");
+ }
+
+ @AfterEach
+ void tearDownRequestContext() {
+ if (requestTraceScope != null) {
+ requestTraceScope.close();
+ requestTraceScope = null;
+ }
+ TraceContext.setCurrentTraceData(null);
+ }
+
+ protected void bindCaller(String callerId) {
+ bindTraceContext(null, callerId, callerId, callerId, null, null, null, null);
+ }
+
+ protected void bindCallerWithAuditMetadata(String callerId) {
+ bindTraceContext(
+ "00-4bf92f3577b34da6a3ce929d0e0e4736-00aa0ba902b70000-01",
+ "user-id-42",
+ "alice",
+ "alice@example.com",
+ "external",
+ "req-123",
+ "2026-01-05T10:15:30Z",
+ "rojo=00f067aa0ba902b7"
+ );
+ }
+
+ private void bindTraceContext(
+ String traceparent,
+ String userId,
+ String username,
+ String email,
+ String realm,
+ String requestId,
+ String requestDeadline,
+ String tracestate
+ ) {
+ var traceData = new TraceData();
+ traceData.getActiveSpan().getSpan().setTraceId("audit-trace-id");
+ var metadata = traceData.getActiveSpan().getCustomMetadata();
+ metadata.putValue("user-identity.id", userId);
+ metadata.putValue("user-identity.username", username);
+ metadata.putValue("user-identity.email", email);
+ metadata.putValue("user-identity.realm", realm);
+ metadata.putValue("user-identity.X-Request-ID", requestId);
+ metadata.putValue("user-identity.X-Request-Deadline", requestDeadline);
+ TraceContext.setCurrentTraceData(traceData);
+ bindOpenTelemetryContext(traceparent, tracestate);
+ }
+
+ private void bindOpenTelemetryContext(String traceparent, String tracestate) {
+ if (!StringUtils.hasText(traceparent)) {
+ return;
+ }
+ var parts = traceparent.split("-");
+ var traceStateBuilder = TraceState.builder();
+ if (StringUtils.hasText(tracestate)) {
+ for (var member : tracestate.split(",")) {
+ var keyValue = member.trim().split("=", 2);
+ traceStateBuilder.put(keyValue[0], keyValue[1]);
+ }
+ }
+ var spanContext = SpanContext.createFromRemoteParent(
+ parts[1],
+ parts[2],
+ TraceFlags.fromHex(parts[3], 0),
+ traceStateBuilder.build()
+ );
+ requestTraceScope = Span.wrap(spanContext).makeCurrent();
+ }
+
+ private static EmbeddedPostgres startPostgres() {
+ try {
+ return EmbeddedPostgres.start();
+ } catch (IOException ex) {
+ throw new IllegalStateException("Failed to start embedded PostgreSQL for test", ex);
+ }
+ }
+
+ public static class StubFileStorageService implements FileStorageService {
+
+ private final AtomicInteger uploadSequence = new AtomicInteger(0);
+ private final AtomicReference lastFileId = new AtomicReference<>();
+ private final AtomicReference lastExpiresAt = new AtomicReference<>();
+ private final Map storedContent = new ConcurrentHashMap<>();
+ private volatile boolean failUploads;
+ private volatile CountDownLatch uploadEnteredLatch;
+ private volatile CountDownLatch releaseUploadLatch;
+
+ private String storeFile(String fileName, String contentType, byte[] content, Instant expiresAt) {
+ if (failUploads) {
+ throw new IllegalStateException("upload failed");
+ }
+ var enteredLatch = uploadEnteredLatch;
+ if (enteredLatch != null) {
+ enteredLatch.countDown();
+ }
+ var releaseLatch = releaseUploadLatch;
+ if (releaseLatch != null) {
+ try {
+ if (!releaseLatch.await(5, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Timed out waiting to release stub upload");
+ }
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Interrupted while waiting to release stub upload", ex);
+ }
+ }
+ var fileId = "stored-file-" + uploadSequence.incrementAndGet();
+ storedContent.put(fileId, content);
+ lastFileId.set(fileId);
+ lastExpiresAt.set(expiresAt);
+ return fileId;
+ }
+
+ public String storeFile(String fileName, String contentType, Path contentPath, Instant expiresAt) {
+ try {
+ return storeFile(fileName, contentType, Files.readAllBytes(contentPath), expiresAt);
+ } catch (IOException ex) {
+ throw new IllegalStateException("Failed to read staged file from stub storage", ex);
+ }
+ }
+
+ @Override
+ public String generateDownloadUrl(String fileId, Instant expiresAt) {
+ lastFileId.set(fileId);
+ lastExpiresAt.set(expiresAt);
+ return "https://download.example/" + fileId;
+ }
+
+ public void reset() {
+ uploadSequence.set(0);
+ lastFileId.set(null);
+ lastExpiresAt.set(null);
+ storedContent.clear();
+ failUploads = false;
+ uploadEnteredLatch = null;
+ releaseUploadLatch = null;
+ }
+
+ public String getLastFileId() {
+ return lastFileId.get();
+ }
+
+ public Instant getLastExpiresAt() {
+ return lastExpiresAt.get();
+ }
+
+ public byte[] getStoredContent(String fileId) {
+ return storedContent.get(fileId);
+ }
+
+ public void setFailUploads(boolean failUploads) {
+ this.failUploads = failUploads;
+ }
+
+ public void blockUploads(CountDownLatch uploadEnteredLatch, CountDownLatch releaseUploadLatch) {
+ this.uploadEnteredLatch = uploadEnteredLatch;
+ this.releaseUploadLatch = releaseUploadLatch;
+ }
+
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/config/ReportingIntegrationTestConfig.java b/src/test/java/dev/vality/ccreporter/integration/config/ReportingIntegrationTestConfig.java
new file mode 100644
index 0000000..810610f
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/config/ReportingIntegrationTestConfig.java
@@ -0,0 +1,19 @@
+package dev.vality.ccreporter.integration.config;
+
+import dev.vality.ccreporter.integration.base.AbstractReportingIntegrationTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+
+/**
+ * Поднимает тестовые бины для integration-тестов, чтобы не ходить во внешний file storage.
+ */
+@TestConfiguration
+public class ReportingIntegrationTestConfig {
+
+ @Bean
+ @Primary
+ AbstractReportingIntegrationTest.StubFileStorageService stubFileStorageClient() {
+ return new AbstractReportingIntegrationTest.StubFileStorageService();
+ }
+}
diff --git a/src/test/java/dev/vality/ccreporter/integration/support/KafkaIntegrationTestSupport.java b/src/test/java/dev/vality/ccreporter/integration/support/KafkaIntegrationTestSupport.java
new file mode 100644
index 0000000..93f1643
--- /dev/null
+++ b/src/test/java/dev/vality/ccreporter/integration/support/KafkaIntegrationTestSupport.java
@@ -0,0 +1,118 @@
+package dev.vality.ccreporter.integration.support;
+
+import dev.vality.ccreporter.serde.thrift.ThriftSerializer;
+import dev.vality.damsel.domain_config_v2.HistoricalCommit;
+import dev.vality.machinegun.eventsink.MachineEvent;
+import dev.vality.machinegun.eventsink.SinkEvent;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.serialization.ByteArraySerializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.kafka.config.KafkaListenerEndpointRegistry;
+import org.springframework.kafka.listener.MessageListenerContainer;
+import org.springframework.kafka.test.EmbeddedKafkaBroker;
+import org.springframework.kafka.test.utils.ContainerTestUtils;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.locks.LockSupport;
+import java.util.function.Predicate;
+
+/**
+ * Выносит из Kafka integration-тестов техническую обвязку вокруг producer и ожидания записей в базе.
+ */
+public final class KafkaIntegrationTestSupport {
+
+ private static final ThriftSerializer THRIFT_SERIALIZER = new ThriftSerializer<>();
+ private static final ThriftSerializer HISTORICAL_COMMIT_THRIFT_SERIALIZER =
+ new ThriftSerializer<>();
+
+ private KafkaIntegrationTestSupport() {
+ }
+
+ public static void waitForAssignments(
+ KafkaListenerEndpointRegistry kafkaListenerEndpointRegistry,
+ EmbeddedKafkaBroker embeddedKafkaBroker
+ ) {
+ for (MessageListenerContainer listenerContainer : kafkaListenerEndpointRegistry.getListenerContainers()) {
+ ContainerTestUtils.waitForAssignment(
+ listenerContainer,
+ embeddedKafkaBroker.getPartitionsPerTopic()
+ );
+ }
+ }
+
+ public static void sendBatch(
+ EmbeddedKafkaBroker embeddedKafkaBroker,
+ String topic,
+ List machineEvents
+ ) throws Exception {
+ try (KafkaProducer producer = new KafkaProducer<>(
+ producerProperties(embeddedKafkaBroker)
+ )) {
+ for (MachineEvent machineEvent : machineEvents) {
+ var sinkEvent = new SinkEvent();
+ sinkEvent.setEvent(machineEvent);
+ var payload = THRIFT_SERIALIZER.serialize("", sinkEvent);
+ producer.send(new ProducerRecord<>(topic, machineEvent.getSourceId(), payload)).get();
+ }
+ producer.flush();
+ }
+ }
+
+ public static void sendDominantBatch(
+ EmbeddedKafkaBroker embeddedKafkaBroker,
+ String topic,
+ List commits
+ ) throws Exception {
+ try (KafkaProducer producer = new KafkaProducer<>(
+ producerProperties(embeddedKafkaBroker)
+ )) {
+ for (HistoricalCommit commit : commits) {
+ var payload = HISTORICAL_COMMIT_THRIFT_SERIALIZER.serialize("", commit);
+ producer.send(new ProducerRecord<>(topic, String.valueOf(commit.getVersion()), payload)).get();
+ }
+ producer.flush();
+ }
+ }
+
+ public static Map waitForRow(
+ JdbcTemplate jdbcTemplate,
+ Duration timeout,
+ String sql,
+ Predicate