From 0ef45861e7d874015c576ef58b24917e4531bede Mon Sep 17 00:00:00 2001 From: WagerMeister <237792185+WagerMeister@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:02:03 +0700 Subject: [PATCH] Full init code snapshot for review --- .github/workflows/build.yml | 10 + .github/workflows/deploy.yml | 15 + .gitignore | 87 + CODEOWNERS | 1 + LICENSE | 176 ++ README.md | 79 + docs/PLAN.md | 376 +++ docs/docs.md | 88 + mockgit.txt | 25 + pom.xml | 339 +++ .../vality/ccreporter/ServiceApplication.java | 15 + .../ccreporter/config/FileStorageConfig.java | 30 + .../ccreporter/config/JacksonConfig.java | 20 + .../vality/ccreporter/config/JooqConfig.java | 29 + .../config/KafkaIngestionConfig.java | 162 ++ .../ccreporter/config/ReportWorkerConfig.java | 36 + .../ccreporter/config/SchedulingConfig.java | 9 + .../config/ThriftEndpointConfig.java | 51 + .../config/properties/CcrApiProperties.java | 16 + .../config/properties/CcrKafkaProperties.java | 38 + .../properties/FileStorageProperties.java | 15 + .../config/properties/ReportProperties.java | 23 + .../constants/ReportAuditEventType.java | 16 + .../ccreporter/dao/DominantLookupDao.java | 140 ++ .../ccreporter/dao/PaymentTxnCurrentDao.java | 64 + .../vality/ccreporter/dao/ReportAuditDao.java | 36 + .../ccreporter/dao/ReportCommandDao.java | 91 + .../vality/ccreporter/dao/ReportCsvDao.java | 232 ++ .../ccreporter/dao/ReportLifecycleDao.java | 188 ++ .../vality/ccreporter/dao/ReportQueryDao.java | 126 + .../ccreporter/dao/WithdrawalSessionDao.java | 48 + .../dao/WithdrawalTxnCurrentDao.java | 61 + .../dao/mapper/ReportAuditMapper.java | 54 + .../dao/mapper/ReportRecordMapper.java | 36 + .../dao/support/DaoUpsertUtils.java | 65 + .../DominantLookupIngestionService.java | 146 ++ .../payment/PaymentEventProjector.java | 246 ++ .../payment/PaymentIngestionService.java | 33 + .../payment/support/PaymentToolExtractor.java | 22 + .../payment/support/ProxyStateExtractor.java | 36 + .../support/TransactionExtraExtractor.java | 55 + .../cashflow/CashFlowAmountExtractor.java | 57 + .../status/FailureSummaryExtractor.java | 63 + .../shared/status/StatusDetailExtractor.java | 39 + .../withdrawal/WithdrawalEventProjector.java | 141 ++ .../WithdrawalIngestionService.java | 33 + .../WithdrawalSessionEventProjector.java | 57 + .../WithdrawalSessionIngestionService.java | 33 + .../kafka/listener/DominantEventListener.java | 35 + .../kafka/listener/PaymentEventListener.java | 36 + .../listener/WithdrawalEventListener.java | 36 + .../WithdrawalSessionEventListener.java | 36 + .../kafka/support/BatchConsumerLogUtil.java | 49 + .../support/BatchLoggingKafkaListener.java | 26 + .../ccreporter/model/DownloadableFile.java | 8 + .../ccreporter/model/GeneratedCsvReport.java | 16 + .../ccreporter/model/ReportProjection.java | 10 + .../vality/ccreporter/model/ReportTask.java | 12 + .../model/RequestAuditMetadata.java | 12 + .../ccreporter/report/ReportAuditService.java | 95 + .../ccreporter/report/ReportCsvService.java | 334 +++ .../report/ReportLifecycleScheduler.java | 19 + .../report/ReportLifecycleService.java | 273 ++ .../report/ReportManagementService.java | 173 ++ .../ccreporter/report/ReportQueryService.java | 50 + .../report/ReportRequestValidator.java | 114 + .../report/mapper/ReportThriftMapper.java | 70 + .../ccreporter/resource/ReportingHandler.java | 70 + .../util/ReportingHandlerLogSupport.java | 69 + .../resource/util/ThriftLoggingHandler.java | 53 + .../RequestAuditMetadataResolver.java | 83 + .../json/ContinuationTokenJsonSerializer.java | 47 + .../serde/json/ThriftJsonCodec.java | 38 + .../serde/thrift/MachineEventParser.java | 14 + .../serde/thrift/ThriftDeserializer.java | 66 + .../storage/FileStorageClientService.java | 61 + .../storage/FileStorageService.java | 11 + .../util/SearchValueNormalizer.java | 18 + .../ccreporter/util/TimestampUtils.java | 38 + src/main/resources/application.yml | 112 + src/main/resources/db/migration/V1__init.sql | 279 +++ .../config/FileStorageConfigTest.java | 21 + .../config/ReportWorkerConfigTest.java | 40 + .../fixture/CurrentStateTableFixtures.java | 152 ++ .../fixture/CurrentStateUpdateFixtures.java | 105 + .../fixture/DominantCommitFixtures.java | 80 + .../PaymentIngestionEventFixtures.java | 357 +++ .../fixture/RealFixtureCoverageTest.java | 37 + .../RealPaymentIngestionEventFixtures.java | 417 +++ .../RealWithdrawalIngestionEventFixtures.java | 426 ++++ .../fixture/ReportRecordFixtures.java | 129 + .../fixture/ReportRequestFixtures.java | 61 + .../SerializedIngestionEventFixtures.java | 76 + .../WithdrawalIngestionEventFixtures.java | 266 ++ .../WithdrawalEventProjectorTest.java | 41 + .../CurrentStateDaoIntegrationTest.java | 270 ++ ...ominantLookupIngestionIntegrationTest.java | 47 + ...estionSerializedEventsIntegrationTest.java | 190 ++ ...stionToReportLifecycleIntegrationTest.java | 298 +++ .../KafkaListenerIntegrationTest.java | 168 ++ .../KafkaListenerRetryIntegrationTest.java | 96 + .../PresignedUrlIntegrationTest.java | 100 + .../ReportAuditIntegrationTest.java | 106 + .../ReportExecutionIntegrationTest.java | 198 ++ ...rtLifecycleConcurrencyIntegrationTest.java | 195 ++ .../ReportLifecycleIntegrationTest.java | 188 ++ .../ReportLifecycleWorkerIntegrationTest.java | 161 ++ .../ReportQueryFilteringIntegrationTest.java | 396 +++ .../integration/ReportingApiSmokeTest.java | 125 + .../AbstractReportingIntegrationTest.java | 259 ++ .../ReportingIntegrationTestConfig.java | 19 + .../support/KafkaIntegrationTestSupport.java | 118 + .../report/ReportCsvServiceTest.java | 96 + .../report/ReportLifecycleServiceTest.java | 125 + .../serde/json/ThriftJsonCodecTest.java | 53 + .../serde/thrift/ThriftSerializer.java | 61 + src/test/resources/payments/2EfF8NQk30a.txt | 2057 +++++++++++++++ src/test/resources/payments/2Ek6RLXFbyi.txt | 1946 ++++++++++++++ src/test/resources/payments/2El3kaBqBU0.txt | 2232 +++++++++++++++++ src/test/resources/payments/2EloA78BbF2.txt | 2063 +++++++++++++++ src/test/resources/payments/2ElsBI5GY4m.txt | 2063 +++++++++++++++ .../resources/payments/2EnbPdxImPo_events.txt | 1817 ++++++++++++++ src/test/resources/payments/response (1).txt | 648 +++++ src/test/resources/payments/response (2).txt | 648 +++++ src/test/resources/payments/response (3).txt | 804 ++++++ src/test/resources/payments/response (4).txt | 798 ++++++ src/test/resources/payments/response (5).txt | 1768 +++++++++++++ src/test/resources/payments/response (6).txt | 759 ++++++ .../resources/withdrawals/211890_events.txt | 1058 ++++++++ src/test/resources/withdrawals/257060.txt | 300 +++ src/test/resources/withdrawals/257072.txt | 300 +++ src/test/resources/withdrawals/257077.txt | 300 +++ src/test/resources/withdrawals/257080.txt | 300 +++ src/test/resources/withdrawals/257085.txt | 300 +++ 134 files changed, 31759 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 CODEOWNERS create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/PLAN.md create mode 100644 docs/docs.md create mode 100644 mockgit.txt create mode 100644 pom.xml create mode 100644 src/main/java/dev/vality/ccreporter/ServiceApplication.java create mode 100644 src/main/java/dev/vality/ccreporter/config/FileStorageConfig.java create mode 100644 src/main/java/dev/vality/ccreporter/config/JacksonConfig.java create mode 100644 src/main/java/dev/vality/ccreporter/config/JooqConfig.java create mode 100644 src/main/java/dev/vality/ccreporter/config/KafkaIngestionConfig.java create mode 100644 src/main/java/dev/vality/ccreporter/config/ReportWorkerConfig.java create mode 100644 src/main/java/dev/vality/ccreporter/config/SchedulingConfig.java create mode 100644 src/main/java/dev/vality/ccreporter/config/ThriftEndpointConfig.java create mode 100644 src/main/java/dev/vality/ccreporter/config/properties/CcrApiProperties.java create mode 100644 src/main/java/dev/vality/ccreporter/config/properties/CcrKafkaProperties.java create mode 100644 src/main/java/dev/vality/ccreporter/config/properties/FileStorageProperties.java create mode 100644 src/main/java/dev/vality/ccreporter/config/properties/ReportProperties.java create mode 100644 src/main/java/dev/vality/ccreporter/constants/ReportAuditEventType.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/DominantLookupDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/PaymentTxnCurrentDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/ReportAuditDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/ReportCommandDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/ReportCsvDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/ReportLifecycleDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/ReportQueryDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/WithdrawalSessionDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/WithdrawalTxnCurrentDao.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/mapper/ReportAuditMapper.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/mapper/ReportRecordMapper.java create mode 100644 src/main/java/dev/vality/ccreporter/dao/support/DaoUpsertUtils.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/dominant/DominantLookupIngestionService.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/payment/PaymentEventProjector.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/payment/PaymentIngestionService.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/payment/support/PaymentToolExtractor.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/payment/support/ProxyStateExtractor.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/payment/support/TransactionExtraExtractor.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/shared/cashflow/CashFlowAmountExtractor.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/shared/status/FailureSummaryExtractor.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/shared/status/StatusDetailExtractor.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalEventProjector.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalIngestionService.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/withdrawal/session/WithdrawalSessionEventProjector.java create mode 100644 src/main/java/dev/vality/ccreporter/ingestion/withdrawal/session/WithdrawalSessionIngestionService.java create mode 100644 src/main/java/dev/vality/ccreporter/kafka/listener/DominantEventListener.java create mode 100644 src/main/java/dev/vality/ccreporter/kafka/listener/PaymentEventListener.java create mode 100644 src/main/java/dev/vality/ccreporter/kafka/listener/WithdrawalEventListener.java create mode 100644 src/main/java/dev/vality/ccreporter/kafka/listener/WithdrawalSessionEventListener.java create mode 100644 src/main/java/dev/vality/ccreporter/kafka/support/BatchConsumerLogUtil.java create mode 100644 src/main/java/dev/vality/ccreporter/kafka/support/BatchLoggingKafkaListener.java create mode 100644 src/main/java/dev/vality/ccreporter/model/DownloadableFile.java create mode 100644 src/main/java/dev/vality/ccreporter/model/GeneratedCsvReport.java create mode 100644 src/main/java/dev/vality/ccreporter/model/ReportProjection.java create mode 100644 src/main/java/dev/vality/ccreporter/model/ReportTask.java create mode 100644 src/main/java/dev/vality/ccreporter/model/RequestAuditMetadata.java create mode 100644 src/main/java/dev/vality/ccreporter/report/ReportAuditService.java create mode 100644 src/main/java/dev/vality/ccreporter/report/ReportCsvService.java create mode 100644 src/main/java/dev/vality/ccreporter/report/ReportLifecycleScheduler.java create mode 100644 src/main/java/dev/vality/ccreporter/report/ReportLifecycleService.java create mode 100644 src/main/java/dev/vality/ccreporter/report/ReportManagementService.java create mode 100644 src/main/java/dev/vality/ccreporter/report/ReportQueryService.java create mode 100644 src/main/java/dev/vality/ccreporter/report/ReportRequestValidator.java create mode 100644 src/main/java/dev/vality/ccreporter/report/mapper/ReportThriftMapper.java create mode 100644 src/main/java/dev/vality/ccreporter/resource/ReportingHandler.java create mode 100644 src/main/java/dev/vality/ccreporter/resource/util/ReportingHandlerLogSupport.java create mode 100644 src/main/java/dev/vality/ccreporter/resource/util/ThriftLoggingHandler.java create mode 100644 src/main/java/dev/vality/ccreporter/security/RequestAuditMetadataResolver.java create mode 100644 src/main/java/dev/vality/ccreporter/serde/json/ContinuationTokenJsonSerializer.java create mode 100644 src/main/java/dev/vality/ccreporter/serde/json/ThriftJsonCodec.java create mode 100644 src/main/java/dev/vality/ccreporter/serde/thrift/MachineEventParser.java create mode 100644 src/main/java/dev/vality/ccreporter/serde/thrift/ThriftDeserializer.java create mode 100644 src/main/java/dev/vality/ccreporter/storage/FileStorageClientService.java create mode 100644 src/main/java/dev/vality/ccreporter/storage/FileStorageService.java create mode 100644 src/main/java/dev/vality/ccreporter/util/SearchValueNormalizer.java create mode 100644 src/main/java/dev/vality/ccreporter/util/TimestampUtils.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/V1__init.sql create mode 100644 src/test/java/dev/vality/ccreporter/config/FileStorageConfigTest.java create mode 100644 src/test/java/dev/vality/ccreporter/config/ReportWorkerConfigTest.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/CurrentStateTableFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/CurrentStateUpdateFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/DominantCommitFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/PaymentIngestionEventFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/RealFixtureCoverageTest.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/RealPaymentIngestionEventFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/RealWithdrawalIngestionEventFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/ReportRecordFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/ReportRequestFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/SerializedIngestionEventFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/fixture/WithdrawalIngestionEventFixtures.java create mode 100644 src/test/java/dev/vality/ccreporter/ingestion/withdrawal/WithdrawalEventProjectorTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/CurrentStateDaoIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/DominantLookupIngestionIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/IngestionSerializedEventsIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/IngestionToReportLifecycleIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/KafkaListenerIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/KafkaListenerRetryIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/PresignedUrlIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/ReportAuditIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/ReportExecutionIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/ReportLifecycleConcurrencyIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/ReportLifecycleIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/ReportLifecycleWorkerIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/ReportQueryFilteringIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/ReportingApiSmokeTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/base/AbstractReportingIntegrationTest.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/config/ReportingIntegrationTestConfig.java create mode 100644 src/test/java/dev/vality/ccreporter/integration/support/KafkaIntegrationTestSupport.java create mode 100644 src/test/java/dev/vality/ccreporter/report/ReportCsvServiceTest.java create mode 100644 src/test/java/dev/vality/ccreporter/report/ReportLifecycleServiceTest.java create mode 100644 src/test/java/dev/vality/ccreporter/serde/json/ThriftJsonCodecTest.java create mode 100644 src/test/java/dev/vality/ccreporter/serde/thrift/ThriftSerializer.java create mode 100644 src/test/resources/payments/2EfF8NQk30a.txt create mode 100644 src/test/resources/payments/2Ek6RLXFbyi.txt create mode 100644 src/test/resources/payments/2El3kaBqBU0.txt create mode 100644 src/test/resources/payments/2EloA78BbF2.txt create mode 100644 src/test/resources/payments/2ElsBI5GY4m.txt create mode 100644 src/test/resources/payments/2EnbPdxImPo_events.txt create mode 100644 src/test/resources/payments/response (1).txt create mode 100644 src/test/resources/payments/response (2).txt create mode 100644 src/test/resources/payments/response (3).txt create mode 100644 src/test/resources/payments/response (4).txt create mode 100644 src/test/resources/payments/response (5).txt create mode 100644 src/test/resources/payments/response (6).txt create mode 100644 src/test/resources/withdrawals/211890_events.txt create mode 100644 src/test/resources/withdrawals/257060.txt create mode 100644 src/test/resources/withdrawals/257072.txt create mode 100644 src/test/resources/withdrawals/257077.txt create mode 100644 src/test/resources/withdrawals/257080.txt create mode 100644 src/test/resources/withdrawals/257085.txt 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 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 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 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> predicate, + Object... args + ) throws InterruptedException { + var deadline = Instant.now().plus(timeout); + while (Instant.now().isBefore(deadline)) { + var rows = jdbcTemplate.queryForList(sql, args); + if (!rows.isEmpty() && predicate.test(rows.getFirst())) { + return rows.getFirst(); + } + LockSupport.parkNanos(Duration.ofMillis(200L).toNanos()); + if (Thread.interrupted()) { + throw new InterruptedException(); + } + } + return jdbcTemplate.queryForList(sql, args).stream() + .filter(predicate) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Kafka listener did not reach expected row state within " + timeout)); + } + + private static Properties producerProperties(EmbeddedKafkaBroker embeddedKafkaBroker) { + var properties = new Properties(); + properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, embeddedKafkaBroker.getBrokersAsString()); + properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + properties.put(ProducerConfig.ACKS_CONFIG, "all"); + properties.put(ProducerConfig.LINGER_MS_CONFIG, "0"); + return properties; + } +} diff --git a/src/test/java/dev/vality/ccreporter/report/ReportCsvServiceTest.java b/src/test/java/dev/vality/ccreporter/report/ReportCsvServiceTest.java new file mode 100644 index 0000000..2774f71 --- /dev/null +++ b/src/test/java/dev/vality/ccreporter/report/ReportCsvServiceTest.java @@ -0,0 +1,96 @@ +package dev.vality.ccreporter.report; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.vality.ccreporter.TimeRange; +import dev.vality.ccreporter.config.properties.ReportProperties; +import dev.vality.ccreporter.dao.ReportCsvDao; +import dev.vality.ccreporter.fixture.ReportRequestFixtures; +import dev.vality.ccreporter.model.ReportTask; +import dev.vality.ccreporter.serde.json.ThriftJsonCodec; +import org.jooq.Cursor; +import org.jooq.Record; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class ReportCsvServiceTest { + + @Test + void paymentsCsvIsRenderedFromDaoCursor() throws Exception { + var reportCsvDao = mock(ReportCsvDao.class); + var objectMapper = new ObjectMapper(); + var thriftJsonCodec = new ThriftJsonCodec(objectMapper); + var reportProperties = new ReportProperties(); + reportProperties.setProcessingTimeoutMs(60_000); + var reportCsvService = new ReportCsvService(reportCsvDao, thriftJsonCodec, reportProperties); + @SuppressWarnings("unchecked") + var cursor = mock(Cursor.class); + var row = mock(Record.class); + + when(reportCsvDao.currentSnapshot()).thenReturn(Instant.parse("2023-11-14T22:13:20.123456789Z")); + when(reportCsvDao.fetchPayments(any())).thenReturn(cursor); + when(cursor.iterator()).thenReturn(List.of(row).iterator()); + when(row.get("created_at", LocalDateTime.class)).thenReturn(LocalDateTime.parse("2026-01-01T10:00:00")); + when(row.get("finalized_at", LocalDateTime.class)) + .thenReturn(LocalDateTime.parse("2026-01-01T11:00:00")); + when(row.get("amount")).thenReturn(1000L); + when(row.get("provider_amount")).thenReturn(990L); + when(row.get("original_amount")).thenReturn(1100L); + when(row.get("converted_amount")).thenReturn(1000L); + when(row.get("invoice_id")).thenReturn("invoice-cursor-1"); + when(row.get("payment_id")).thenReturn("payment-cursor-1"); + when(row.get("status")).thenReturn("captured"); + when(row.get("trx_id")).thenReturn("trx-cursor-1"); + when(row.get("provider_id")).thenReturn("provider-1"); + when(row.get("terminal_id")).thenReturn("terminal-1"); + when(row.get("shop_id")).thenReturn("shop\r1"); + when(row.get("exchange_rate_internal")).thenReturn(new BigDecimal("1.1000000000")); + when(row.get("provider_currency")).thenReturn("EUR"); + when(row.get("original_currency")).thenReturn("USD"); + when(row.get("currency")).thenReturn("RUB"); + when(row.get("currency", String.class)).thenReturn("RUB"); + when(row.get("provider_currency", String.class)).thenReturn("EUR"); + when(row.get("original_currency", String.class)).thenReturn("USD"); + + var generatedCsvReport = reportCsvService.generate(claimedPaymentsJob(thriftJsonCodec)); + + verify(reportCsvDao).setLocalStatementTimeout(60_000); + verify(reportCsvDao).fetchPayments(any()); + verify(cursor).close(); + assertThat(generatedCsvReport.rowsCount()).isEqualTo(1L); + assertThat(generatedCsvReport.dataSnapshotFixedAt()) + .isEqualTo(Instant.parse("2023-11-14T22:13:20.123456789Z")); + var csv = Files.readString(generatedCsvReport.contentPath(), StandardCharsets.UTF_8); + assertThat(csv) + .contains("invoice-cursor-1,payment-cursor-1,captured,10.00,RUB,trx-cursor-1") + .contains("\"shop\r1\"") + .contains("\r\n"); + assertThat(csv.replace("\r\n", "")).doesNotContain("\n"); + + Files.deleteIfExists(generatedCsvReport.contentPath()); + } + + private ReportTask claimedPaymentsJob(ThriftJsonCodec thriftJsonCodec) { + var request = ReportRequestFixtures.payments( + "cursor-fetch-size-1", + new TimeRange("2025-12-31T00:00:00Z", "2026-01-02T00:00:00Z") + ); + request.setTimezone("Asia/Krasnoyarsk"); + var reportQuery = request.getQuery(); + return new ReportTask( + 42L, + dev.vality.ccreporter.domain.enums.ReportType.payments, + thriftJsonCodec.serialize(reportQuery), + request.getTimezone(), + 1 + ); + } +} diff --git a/src/test/java/dev/vality/ccreporter/report/ReportLifecycleServiceTest.java b/src/test/java/dev/vality/ccreporter/report/ReportLifecycleServiceTest.java new file mode 100644 index 0000000..15425e1 --- /dev/null +++ b/src/test/java/dev/vality/ccreporter/report/ReportLifecycleServiceTest.java @@ -0,0 +1,125 @@ +package dev.vality.ccreporter.report; + +import dev.vality.ccreporter.config.properties.ReportProperties; +import dev.vality.ccreporter.dao.ReportLifecycleDao; +import dev.vality.ccreporter.domain.enums.ReportType; +import dev.vality.ccreporter.model.ReportTask; +import dev.vality.ccreporter.storage.FileStorageService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ReportLifecycleServiceTest { + + @Test + void hardTimeoutInterruptsWorkerAndMarksReportTimedOut() throws Exception { + var reportLifecycleDao = mock(ReportLifecycleDao.class); + var reportCsvService = mock(ReportCsvService.class); + var fileStorageService = mock(FileStorageService.class); + var reportProperties = reportProperties(50); + var reportTask = reportTask(1); + var workerStarted = new CountDownLatch(1); + var workerInterrupted = new CountDownLatch(1); + var reportWorkerExecutor = Executors.newVirtualThreadPerTaskExecutor(); + try { + when(reportLifecycleDao.claimNextPendingReport(any())).thenReturn(Optional.of(reportTask)); + when(reportLifecycleDao.markTimedOut(eq(reportTask.id()), any())).thenAnswer(invocation -> { + assertThat(workerInterrupted.await(1, TimeUnit.SECONDS)).isTrue(); + return true; + }); + when(reportCsvService.generate(reportTask)).thenAnswer(invocation -> { + workerStarted.countDown(); + try { + new CountDownLatch(1).await(); + throw new AssertionError("Worker must be interrupted"); + } catch (InterruptedException ex) { + workerInterrupted.countDown(); + Thread.currentThread().interrupt(); + throw new CancellationException("interrupted"); + } + }); + + var service = new ReportLifecycleService( + reportLifecycleDao, + reportCsvService, + fileStorageService, + reportProperties, + reportWorkerExecutor + ); + + var processed = service.processNextPendingReport(Instant.now()); + + assertThat(processed).isTrue(); + assertThat(workerStarted.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(workerInterrupted.await(1, TimeUnit.SECONDS)).isTrue(); + verify(reportLifecycleDao).markTimedOut(eq(reportTask.id()), any()); + verifyNoInteractions(fileStorageService); + } finally { + reportWorkerExecutor.shutdownNow(); + } + } + + @Test + void retryIsScheduledFromFailureTime() { + var reportLifecycleDao = mock(ReportLifecycleDao.class); + var reportCsvService = mock(ReportCsvService.class); + var fileStorageService = mock(FileStorageService.class); + var reportProperties = reportProperties(1_000); + var reportTask = reportTask(1); + var reportWorkerExecutor = Executors.newVirtualThreadPerTaskExecutor(); + try { + when(reportLifecycleDao.claimNextPendingReport(any())).thenReturn(Optional.of(reportTask)); + when(reportLifecycleDao.rescheduleForRetry(anyLong(), any(), anyString(), anyString())).thenReturn(true); + when(reportCsvService.generate(reportTask)).thenThrow(new IllegalStateException("generation failed")); + var service = new ReportLifecycleService( + reportLifecycleDao, + reportCsvService, + fileStorageService, + reportProperties, + reportWorkerExecutor + ); + var beforeFailure = Instant.now(); + + service.processNextPendingReport(Instant.parse("2026-01-01T00:00:00Z")); + + var nextAttemptCaptor = ArgumentCaptor.forClass(Instant.class); + verify(reportLifecycleDao).rescheduleForRetry( + eq(reportTask.id()), + nextAttemptCaptor.capture(), + eq("report_processing_error"), + eq("generation failed") + ); + assertThat(nextAttemptCaptor.getValue()) + .isBetween( + beforeFailure.plus(Duration.ofSeconds(30)), + Instant.now().plus(Duration.ofSeconds(30)) + ); + } finally { + reportWorkerExecutor.shutdownNow(); + } + } + + private ReportProperties reportProperties(long processingTimeoutMs) { + var properties = new ReportProperties(); + properties.setMaxAttempts(2); + properties.setWorkerConcurrency(2); + properties.setProcessingTimeoutMs(processingTimeoutMs); + properties.setExpirationSec(600); + return properties; + } + + private ReportTask reportTask(int attempt) { + return new ReportTask(42L, ReportType.payments, "{}", "UTC", attempt); + } +} diff --git a/src/test/java/dev/vality/ccreporter/serde/json/ThriftJsonCodecTest.java b/src/test/java/dev/vality/ccreporter/serde/json/ThriftJsonCodecTest.java new file mode 100644 index 0000000..58ecb65 --- /dev/null +++ b/src/test/java/dev/vality/ccreporter/serde/json/ThriftJsonCodecTest.java @@ -0,0 +1,53 @@ +package dev.vality.ccreporter.serde.json; + +import dev.vality.ccreporter.*; +import dev.vality.ccreporter.config.JacksonConfig; +import dev.vality.ccreporter.fixture.ReportRequestFixtures; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ThriftJsonCodecTest { + + private final ThriftJsonCodec codec = new ThriftJsonCodec(new JacksonConfig().objectMapper()); + + @Test + void serializesNestedUnionAndEnumsAsReadableJson() { + var request = ReportRequestFixtures.payments("thrift-json-codec-1", new TimeRange( + "2026-01-01T00:00:00Z", + "2026-01-02T00:00:00Z" + )); + request.setReportType(ReportType.payments); + request.setFileType(FileType.csv); + request.setQuery(ReportQuery.payments(request.getQuery().getPayments() + .setPartyIds(List.of("party-1")) + .setFilter(new PaymentsSearchFilter().setShopTerm("shop-term")))); + + var json = codec.serialize(request); + + assertThat(json) + .contains("\"report_type\":\"payments\"") + .contains("\"file_type\":\"csv\"") + .contains("\"query\":{\"payments\"") + .contains("\"time_range\"") + .contains("\"party_ids\"") + .contains("\"shop_term\"") + .doesNotContain("fieldMetaData") + .doesNotContain("setField_") + .doesNotContain("value_"); + } + + @Test + void roundTripsStructWithNestedUnionAndEnums() { + var request = ReportRequestFixtures.withdrawals("thrift-json-codec-2", new TimeRange( + "2026-02-01T00:00:00Z", + "2026-02-02T00:00:00Z" + )); + + var restored = codec.deserialize(codec.serialize(request), CreateReportRequest.class); + + assertThat(restored).isEqualTo(request); + } +} diff --git a/src/test/java/dev/vality/ccreporter/serde/thrift/ThriftSerializer.java b/src/test/java/dev/vality/ccreporter/serde/thrift/ThriftSerializer.java new file mode 100644 index 0000000..2beeec6 --- /dev/null +++ b/src/test/java/dev/vality/ccreporter/serde/thrift/ThriftSerializer.java @@ -0,0 +1,61 @@ +package dev.vality.ccreporter.serde.thrift; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.common.serialization.Serializer; +import org.apache.thrift.TBase; +import org.apache.thrift.TException; +import org.apache.thrift.TSerializer; +import org.apache.thrift.protocol.TBinaryProtocol; +import org.apache.thrift.protocol.TProtocolFactory; + +import java.util.Map; + +@Slf4j +public class ThriftSerializer> implements Serializer { + + private final TProtocolFactory protocolFactory; + private final ThreadLocal thriftSerializer = ThreadLocal.withInitial(this::createSerializer); + + public ThriftSerializer() { + this(new TBinaryProtocol.Factory()); + } + + public ThriftSerializer(TProtocolFactory protocolFactory) { + this.protocolFactory = protocolFactory; + } + + @Override + public void configure(Map configs, boolean isKey) { + log.debug("ThriftSerializer configure: isKey={}", isKey); + } + + @Override + public byte[] serialize(String topic, T data) { + if (data == null) { + return null; + } + try { + return thriftSerializer.get().serialize(data); + } catch (TException ex) { + log.error("Error when serializing thrift data for topic: {}", topic, ex); + throw new RuntimeException(String.format("Failed to serialize thrift data for topic: %s", topic), ex); + } + } + + public byte[] serialize(T data) { + return serialize("unknown", data); + } + + @Override + public void close() { + thriftSerializer.remove(); + } + + private TSerializer createSerializer() { + try { + return new TSerializer(protocolFactory); + } catch (TException ex) { + throw new RuntimeException("Failed to initialize Apache Thrift TSerializer", ex); + } + } +} diff --git a/src/test/resources/payments/2EfF8NQk30a.txt b/src/test/resources/payments/2EfF8NQk30a.txt new file mode 100644 index 0000000..8678b9b --- /dev/null +++ b/src/test/resources/payments/2EfF8NQk30a.txt @@ -0,0 +1,2057 @@ +2026-03-13T15:29:47.804969+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:29:47.809097+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:29:47.809440+00:00 notice: Skipping unknown field [5] with type: struct +[ + { + "id": 1, + "created_at": "2026-03-07T20:57:23Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2EfF8NQk30a", + "domain_revision": 2762, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "created_at": "2026-03-07T20:57:23.297070Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "361892924" + }, + "due": "2026-03-07T21:57:23Z", + "cost": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-df2378f8de73eed57cffa7e9bd6e01c8-0f0a02cddcb47953-01\",\"transaction_id\":\"test-uuid-3\",\"user_id\":\"53559\"}" + }, + "external_id": "test-external-1" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2026-03-07T20:57:23Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2026-03-07T20:57:23.737372Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-uuid-3\",\"user_id\":\"53559\"}" + }, + "cost": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 2762, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "VISA" + }, + "bin": "411111", + "last_digits": "1808", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 4, + "year": 2033 + }, + "category": "REWARDS" + } + }, + "payment_session_id": "679U2PB52lWMyvgL1lrsXA", + "client_info": { + "ip_address": "198.51.100.10", + "fingerprint": "55afc493d6e74bb69b1e19be8c7f7d82", + "peer_ip_address": "198.51.100.11", + "user_ip_address": "198.51.100.12" + } + }, + "contact_info": { + "phone_number": "+70000000001", + "email": "user1@example.test", + "first_name": "Test", + "last_name": "UserOne", + "country": "KAZ", + "state": "Kazakhstan", + "city": "Тараз" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-1" + }, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "make_recurrent": false, + "external_id": "test-external-2", + "processing_deadline": "2026-03-07T20:59:23.707Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2026-03-07T20:57:23.737372Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2026-03-07T20:57:23Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2026-03-07T20:57:23.779650Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2026-03-07T20:57:23Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2026-03-07T20:57:23.794361Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2026-03-07T20:57:24Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "low" + } + }, + "occurred_at": "2026-03-07T20:57:23.809478Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2026-03-07T20:57:24Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "candidates": [ + { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 3, + "route_pin": 0, + "random_condition": 20, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 3, + "route_pin": 0, + "random_condition": 40, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 10000000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 10000000 + } + ] + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-07T20:57:24.069000Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2026-03-07T20:57:24Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 970000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "volume": { + "amount": 500000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-07T20:57:24.850667Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2026-03-07T20:57:24Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-07T20:57:24.941794Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2026-03-07T20:57:25Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "2EfF8NQk30a.1", + "extra": { + "term_url": "https://example.com/test-url-2" + } + } + } + } + } + }, + "occurred_at": "2026-03-07T20:57:24.957559Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-07T20:57:24.957559Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + }, + "occurred_at": "2026-03-07T20:57:24.957559Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2026-03-07T20:57:25Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 3, + "route_pin": 0, + "random_condition": 20, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 10000000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 10000000 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-07T20:57:25.326478Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2026-03-07T20:57:26Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 970000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 750000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 19800, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-07T20:57:25.980952Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2026-03-07T20:57:26Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-07T20:57:26.055697Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2026-03-07T20:57:39Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "100000000001", + "extra": { + "kzt_to_usd_rate": "493.85", + "kzt_to_usd_converted_amount": "20249" + } + } + } + } + } + }, + "occurred_at": "2026-03-07T20:57:26.070750Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30600' - 'reached.processing.limits.capacity.please.contact.your.account.manager'" + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-07T20:57:26.070750Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30600' - 'reached.processing.limits.capacity.please.contact.your.account.manager'" + } + } + } + }, + "occurred_at": "2026-03-07T20:57:26.070750Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2026-03-07T20:57:39Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 10000000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 10000000 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-07T20:57:39.036043Z" + } + } + ] + } + }, + { + "id": 15, + "created_at": "2026-03-07T20:57:39Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 1077000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 700000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 27000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-07T20:57:39.841143Z" + } + } + ] + } + }, + { + "id": 16, + "created_at": "2026-03-07T20:57:39Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-07T20:57:39.897189Z" + } + } + ] + } + }, + { + "id": 17, + "created_at": "2026-03-07T20:57:41Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-1", + "extra": [] + } + } + } + } + }, + "occurred_at": "2026-03-07T20:57:39.912551Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"CHECK_STATUS\",\"providerTrxId\":\"test-provider-trx-1\",\"pollingInfo\":{\"start_date_time_polling\":1772917061.268015879,\"max_date_time_polling\":1772920661.268015879}}" + } + } + } + }, + "occurred_at": "2026-03-07T20:57:39.912551Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-3" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2026-03-07T20:57:39.912551Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_suspended": { + "tag": "FLEXIFY-2EfF8NQk30a-1", + "timeout_behaviour": { + "callback": "{\"tag\":\"2EfF8NQk30a-1\"}" + } + } + } + } + }, + "occurred_at": "2026-03-07T20:57:39.912551Z" + } + } + ] + } + }, + { + "id": 18, + "created_at": "2026-03-07T20:59:02Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_activated": [] + } + } + }, + "occurred_at": "2026-03-07T20:59:02.550820Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-4" + } + } + }, + "status": { + "completed": [] + } + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.550820Z" + } + } + ] + } + }, + { + "id": 19, + "created_at": "2026-03-07T20:59:02Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-1", + "extra": [], + "additional_info": { + "rrn": "2EfF8NQk30a-1", + "extra_payment_info": { + "currency": "EUR" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.620179Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-1\"}" + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.620179Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.620179Z" + } + } + ] + } + }, + { + "id": 20, + "created_at": "2026-03-07T20:59:02Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "processed": [] + } + } + }, + "occurred_at": "2026-03-07T20:59:02.767962Z" + } + } + ] + } + }, + { + "id": 21, + "created_at": "2026-03-07T20:59:02Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_capture_started": { + "data": { + "reason": "Timeout", + "cash": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.782791Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-07T20:59:02.782791Z" + } + } + ] + } + }, + { + "id": 22, + "created_at": "2026-03-07T20:59:02Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-1", + "extra": { + "kzt_to_eur_rate": "571.78", + "kzt_to_eur_converted_amount": "17489" + }, + "additional_info": { + "rrn": "2EfF8NQk30a-1", + "extra_payment_info": { + "kzt_to_eur_rate": "571.78", + "kzt_to_eur_converted_amount": "17489" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.795288Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-1\"}" + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.795288Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.795288Z" + } + } + ] + } + }, + { + "id": 23, + "created_at": "2026-03-07T20:59:03Z", + "source": { + "invoice_id": "2EfF8NQk30a" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + } + }, + "occurred_at": "2026-03-07T20:59:02.869325Z" + } + }, + { + "invoice_status_changed": { + "status": { + "paid": [] + } + } + } + ] + } + } +] diff --git a/src/test/resources/payments/2Ek6RLXFbyi.txt b/src/test/resources/payments/2Ek6RLXFbyi.txt new file mode 100644 index 0000000..19be949 --- /dev/null +++ b/src/test/resources/payments/2Ek6RLXFbyi.txt @@ -0,0 +1,1946 @@ +2026-03-13T15:30:26.410898+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:30:26.418250+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:30:26.418792+00:00 notice: Skipping unknown field [5] with type: struct +[ + { + "id": 1, + "created_at": "2026-03-10T19:13:45Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2Ek6RLXFbyi", + "domain_revision": 2812, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "created_at": "2026-03-10T19:13:45.286721Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "369011264" + }, + "due": "2026-03-10T20:13:45Z", + "cost": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-ddc3732dc5503dd73bd57c045c9ad398-e311ece37ce05b9b-01\",\"transaction_id\":\"test-uuid-5\",\"user_id\":\"99321\"}" + }, + "external_id": "test-external-3" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2026-03-10T19:13:45Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2026-03-10T19:13:45.703049Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-uuid-5\",\"user_id\":\"99321\"}" + }, + "cost": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 2812, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "MASTERCARD" + }, + "bin": "411111", + "last_digits": "5406", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 11, + "year": 2030 + }, + "category": "PLATINUM" + } + }, + "payment_session_id": "4TDzeqDDrTdBqDpmwfO6tm", + "client_info": { + "ip_address": "198.51.100.13", + "fingerprint": "946d9ee9d584444e8136a35943777eb0", + "peer_ip_address": "198.51.100.14", + "user_ip_address": "198.51.100.15" + } + }, + "contact_info": { + "phone_number": "+70000000002", + "email": "user2@example.test", + "first_name": "Test", + "last_name": "UserTwo", + "country": "KAZ", + "state": "Kazakhstan", + "city": "Рудный" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-5" + }, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "make_recurrent": false, + "external_id": "test-external-4", + "processing_deadline": "2026-03-10T19:15:45.683Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2026-03-10T19:13:45.703049Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2026-03-10T19:13:45Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2026-03-10T19:13:45.723010Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2026-03-10T19:13:45Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2026-03-10T19:13:45.732214Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2026-03-10T19:13:45Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "low" + } + }, + "occurred_at": "2026-03-10T19:13:45.742113Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2026-03-10T19:13:46Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "candidates": [ + { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 300100 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 300100 + } + ] + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-10T19:13:46.002403Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2026-03-10T19:13:46Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 48510, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "volume": { + "amount": 15005, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-10T19:13:46.767497Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2026-03-10T19:13:46Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-10T19:13:46.922076Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2026-03-10T19:13:47Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "2Ek6RLXFbyi.1", + "extra": { + "term_url": "https://example.com/test-url-6" + } + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:46.932362Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:46.932362Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:46.932362Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2026-03-10T19:13:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 300100 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 300100 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-10T19:13:47.241032Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2026-03-10T19:13:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 48510, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 22508, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 19800, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-10T19:13:48.298924Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2026-03-10T19:13:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-10T19:13:48.365096Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2026-03-10T19:13:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "100000000002", + "extra": { + "kzt_to_usd_rate": "491.59", + "kzt_to_usd_converted_amount": "610" + } + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:48.376165Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30500' - 'code: 30003 reason: no appropriate payment routes found'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:48.376165Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30500' - 'code: 30003 reason: no appropriate payment routes found'", + "sub": { + "code": "unknown" + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:48.376165Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2026-03-10T19:13:49Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 300100 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 300100 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-10T19:13:48.541537Z" + } + } + ] + } + }, + { + "id": 15, + "created_at": "2026-03-10T19:13:49Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 58511, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 21007, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 27000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-10T19:13:49.189579Z" + } + } + ] + } + }, + { + "id": 16, + "created_at": "2026-03-10T19:13:49Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-10T19:13:49.235102Z" + } + } + ] + } + }, + { + "id": 17, + "created_at": "2026-03-10T19:13:50Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-2", + "extra": [] + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:49.247116Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"CHECK_STATUS\",\"providerTrxId\":\"test-provider-trx-2\",\"pollingInfo\":{\"start_date_time_polling\":1773170030.245751044,\"max_date_time_polling\":1773173630.245751044}}" + } + } + } + }, + "occurred_at": "2026-03-10T19:13:49.247116Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-7" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:49.247116Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_suspended": { + "tag": "FLEXIFY-2Ek6RLXFbyi-1", + "timeout_behaviour": { + "callback": "{\"tag\":\"2Ek6RLXFbyi-1\"}" + } + } + } + } + }, + "occurred_at": "2026-03-10T19:13:49.247116Z" + } + } + ] + } + }, + { + "id": 18, + "created_at": "2026-03-10T19:14:47Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_activated": [] + } + } + }, + "occurred_at": "2026-03-10T19:14:47.825201Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-8" + } + } + }, + "status": { + "completed": [] + } + } + } + } + }, + "occurred_at": "2026-03-10T19:14:47.825201Z" + } + } + ] + } + }, + { + "id": 19, + "created_at": "2026-03-10T19:14:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-2", + "extra": [], + "additional_info": { + "rrn": "2Ek6RLXFbyi-1", + "extra_payment_info": { + "currency": "EUR" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-10T19:14:47.893523Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-2\"}" + } + } + } + }, + "occurred_at": "2026-03-10T19:14:47.893523Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-10T19:14:47.893523Z" + } + } + ] + } + }, + { + "id": 20, + "created_at": "2026-03-10T19:14:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "processed": [] + } + } + }, + "occurred_at": "2026-03-10T19:14:48.088280Z" + } + } + ] + } + }, + { + "id": 21, + "created_at": "2026-03-10T19:14:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_capture_started": { + "data": { + "reason": "Timeout", + "cash": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + }, + "occurred_at": "2026-03-10T19:14:48.106595Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-10T19:14:48.106595Z" + } + } + ] + } + }, + { + "id": 22, + "created_at": "2026-03-10T19:14:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-2", + "extra": { + "kzt_to_eur_rate": "571.78", + "kzt_to_eur_converted_amount": "525" + }, + "additional_info": { + "rrn": "2Ek6RLXFbyi-1", + "extra_payment_info": { + "kzt_to_eur_rate": "571.78", + "kzt_to_eur_converted_amount": "525" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-10T19:14:48.123942Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-2\"}" + } + } + } + }, + "occurred_at": "2026-03-10T19:14:48.123942Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-10T19:14:48.123942Z" + } + } + ] + } + }, + { + "id": 23, + "created_at": "2026-03-10T19:14:48Z", + "source": { + "invoice_id": "2Ek6RLXFbyi" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 300100, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + } + }, + "occurred_at": "2026-03-10T19:14:48.202420Z" + } + }, + { + "invoice_status_changed": { + "status": { + "paid": [] + } + } + } + ] + } + } +] diff --git a/src/test/resources/payments/2El3kaBqBU0.txt b/src/test/resources/payments/2El3kaBqBU0.txt new file mode 100644 index 0000000..46d476b --- /dev/null +++ b/src/test/resources/payments/2El3kaBqBU0.txt @@ -0,0 +1,2232 @@ +2026-03-13T15:31:15.993615+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:31:15.997560+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:31:15.997848+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:31:15.998039+00:00 notice: Skipping unknown field [5] with type: struct +[ + { + "id": 1, + "created_at": "2026-03-11T09:03:43Z", + "source": { + "invoice_id": "2El3kaBqBU02El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2El3kaBqBU0", + "domain_revision": 2842, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "created_at": "2026-03-11T09:03:43.294588Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "370167977" + }, + "due": "2026-03-11T10:03:43Z", + "cost": { + "amount": 300000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-1827423bcac47e656c721a4ee64d414b-539ac427e461b2fa-01\",\"transaction_id\":\"test-uuid-7\",\"user_id\":\"321042\"}" + }, + "external_id": "test-external-5" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2026-03-11T09:03:43Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2026-03-11T09:03:43.820801Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-uuid-7\",\"user_id\":\"321042\"}" + }, + "cost": { + "amount": 300000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 2842, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "VISA" + }, + "bin": "411111", + "last_digits": "1488", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 9, + "year": 2026 + }, + "category": "REWARDS" + } + }, + "payment_session_id": "3FUzG5Q3EjudjT9nq9CUyh", + "client_info": { + "ip_address": "198.51.100.16", + "fingerprint": "41dea57f8b7a46329444216c8c65564d", + "peer_ip_address": "198.51.100.17", + "user_ip_address": "198.51.100.18" + } + }, + "contact_info": { + "phone_number": "+70000000003", + "email": "user3@example.test", + "first_name": "Test", + "last_name": "UserThree", + "country": "KAZ", + "state": "Kazakhstan" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-9" + }, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "make_recurrent": false, + "external_id": "test-external-6", + "processing_deadline": "2026-03-11T09:05:43.793Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2026-03-11T09:03:43.820801Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2026-03-11T09:03:43Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2026-03-11T09:03:43.847179Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2026-03-11T09:03:43Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2026-03-11T09:03:43.864222Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2026-03-11T09:03:44Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "low" + } + }, + "occurred_at": "2026-03-11T09:03:43.880980Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2026-03-11T09:03:45Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "candidates": [ + { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 3, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_count" + }, + "upper_boundary": 10, + "domain_revision": 2155 + }, + "value": 1 + } + ] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 300000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 300000 + } + ] + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-11T09:03:44.163646Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2026-03-11T09:03:45Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 48500, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 300000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "volume": { + "amount": 15000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T09:03:45.492716Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2026-03-11T09:03:45Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T09:03:45.599593Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2026-03-11T09:08:40Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "2El3kaBqBU0.1", + "extra": { + "term_url": "https://example.com/test-url-10" + } + } + } + } + } + }, + "occurred_at": "2026-03-11T09:08:40.168028Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "preauthorization_failed", + "reason": "'422' - 'The order id has already been taken.'" + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T09:08:40.168028Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "preauthorization_failed", + "reason": "'422' - 'The order id has already been taken.'" + } + } + } + }, + "occurred_at": "2026-03-11T09:08:40.168028Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2026-03-11T09:08:41Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 3, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_count" + }, + "upper_boundary": 10, + "domain_revision": 2155 + }, + "value": 1 + } + ] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 300000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 300000 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-11T09:08:40.405687Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2026-03-11T09:08:41Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 48500, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 300000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 22500, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 19800, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T09:08:41.569896Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2026-03-11T09:08:41Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "sub": { + "code": "processing_deadline_reached" + } + } + } + } + }, + "occurred_at": "2026-03-11T09:08:41.662626Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2026-03-11T09:08:43Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 3, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 129 + }, + "terminal": { + "id": 29 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_count" + }, + "upper_boundary": 10, + "domain_revision": 2155 + }, + "value": 1 + } + ] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 300000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 300000 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-11T09:08:41.724534Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2026-03-11T09:08:43Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 48500, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 56429, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 129 + }, + "terminal_ref": { + "id": 29 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 300000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 56429, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 129 + }, + "terminal_ref": { + "id": 29 + } + } + } + } + }, + "volume": { + "amount": 32000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T09:08:43.475248Z" + } + } + ] + } + }, + { + "id": 15, + "created_at": "2026-03-11T09:08:43Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "sub": { + "code": "processing_deadline_reached" + } + } + } + } + }, + "occurred_at": "2026-03-11T09:08:43.543247Z" + } + } + ] + } + }, + { + "id": 16, + "created_at": "2026-03-11T09:08:44Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 300000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 300000 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-11T09:08:43.567470Z" + } + } + ] + } + }, + { + "id": 17, + "created_at": "2026-03-11T09:08:44Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 58500, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 300000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 21000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 27000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T09:08:44.307902Z" + } + } + ] + } + }, + { + "id": 18, + "created_at": "2026-03-11T09:08:44Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "sub": { + "code": "processing_deadline_reached" + } + } + } + } + }, + "occurred_at": "2026-03-11T09:08:44.349095Z" + } + } + ] + } + }, + { + "id": 19, + "created_at": "2026-03-11T09:08:44Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "sub": { + "code": "processing_deadline_reached" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T09:08:44.368233Z" + } + } + ] + } + }, + { + "id": 20, + "created_at": "2026-03-11T10:03:43Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_status_changed": { + "status": { + "cancelled": { + "details": "overdue" + } + } + } + } + ] + } + }, + { + "id": 21, + "created_at": "2026-03-11T10:05:52Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_adjustment_change": { + "id": "1", + "payload": { + "invoice_payment_adjustment_created": { + "adjustment": { + "id": "1", + "status": { + "pending": [] + }, + "created_at": "2026-03-11T10:05:52.422347Z", + "domain_revision": 2842, + "reason": "Сверка по лк провайдера", + "new_cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 58500, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 300000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 21000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 27000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ], + "old_cash_flow_inverse": [], + "state": { + "status_change": { + "scenario": { + "target_status": { + "captured": [] + } + } + } + } + } + } + } + } + } + } + } + ] + } + }, + { + "id": 22, + "created_at": "2026-03-11T10:05:52Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_adjustment_change": { + "id": "1", + "payload": { + "invoice_payment_adjustment_status_changed": { + "status": { + "processed": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T10:05:52.523831Z" + } + } + ] + } + }, + { + "id": 23, + "created_at": "2026-03-11T10:05:52Z", + "source": { + "invoice_id": "2El3kaBqBU0" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_adjustment_change": { + "id": "1", + "payload": { + "invoice_payment_adjustment_status_changed": { + "status": { + "captured": { + "at": "2026-03-11T10:05:52.554372Z" + } + } + } + } + } + }, + "occurred_at": "2026-03-11T10:05:52.554372Z" + } + }, + { + "invoice_status_changed": { + "status": { + "paid": [] + } + } + } + ] + } + } +] diff --git a/src/test/resources/payments/2EloA78BbF2.txt b/src/test/resources/payments/2EloA78BbF2.txt new file mode 100644 index 0000000..0dcda8b --- /dev/null +++ b/src/test/resources/payments/2EloA78BbF2.txt @@ -0,0 +1,2063 @@ +2026-03-13T15:31:39.292693+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:31:39.296461+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:31:39.296794+00:00 notice: Skipping unknown field [5] with type: struct +[ + { + "id": 1, + "created_at": "2026-03-11T19:53:11Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2EloA78BbF2", + "domain_revision": 2901, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "created_at": "2026-03-11T19:53:11.469880Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "371640644" + }, + "due": "2026-03-11T20:53:11Z", + "cost": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-dfd2b7870d367d7259c8ad738f12f131-2dac6937a68d9893-01\",\"transaction_id\":\"test-uuid-8\",\"user_id\":\"50400\"}" + }, + "external_id": "test-external-7" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2026-03-11T19:53:11Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2026-03-11T19:53:11.850317Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-uuid-8\",\"user_id\":\"50400\"}" + }, + "cost": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 2901, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "MASTERCARD" + }, + "bin": "411111", + "last_digits": "5288", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 6, + "year": 2026 + }, + "category": "PLATINUM" + } + }, + "payment_session_id": "510l3Ou9KdSoAtAWbGJQEK", + "client_info": { + "ip_address": "198.51.100.19", + "fingerprint": "74bd837bd785493cab84d1e67a3a493e", + "peer_ip_address": "198.51.100.20", + "user_ip_address": "198.51.100.21" + } + }, + "contact_info": { + "phone_number": "+70000000004", + "email": "user4@example.test", + "first_name": "Test", + "last_name": "UserFour", + "country": "KAZ", + "state": "Kazakhstan", + "city": "Алматы" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-11" + }, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "make_recurrent": false, + "external_id": "test-external-8", + "processing_deadline": "2026-03-11T19:55:11.822Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2026-03-11T19:53:11.850317Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2026-03-11T19:53:11Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2026-03-11T19:53:11.874406Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2026-03-11T19:53:11Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2026-03-11T19:53:11.892348Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2026-03-11T19:53:12Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "low" + } + }, + "occurred_at": "2026-03-11T19:53:11.908650Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2026-03-11T19:53:12Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "candidates": [ + { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 1000000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 1000000 + } + ] + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-11T19:53:12.218389Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2026-03-11T19:53:12Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 115000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "volume": { + "amount": 50000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T19:53:12.910783Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2026-03-11T19:53:12Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T19:53:12.979979Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2026-03-11T19:53:13Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "2EloA78BbF2.1", + "extra": { + "term_url": "https://example.com/test-url-12" + } + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:12.994949Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:12.994949Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:12.994949Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2026-03-11T19:53:13Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 1000000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 1000000 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-11T19:53:13.278982Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2026-03-11T19:53:14Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 115000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 75000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 19800, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T19:53:13.998229Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2026-03-11T19:53:14Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T19:53:14.071085Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2026-03-11T19:53:14Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "100000000003", + "extra": { + "kzt_to_usd_rate": "489.39", + "kzt_to_usd_converted_amount": "2043" + } + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:14.088071Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30500' - 'code: 30003 reason: no appropriate payment routes found'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:14.088071Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30500' - 'code: 30003 reason: no appropriate payment routes found'", + "sub": { + "code": "unknown" + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:14.088071Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2026-03-11T19:53:14Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 1000000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 1000000 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-11T19:53:14.289985Z" + } + } + ] + } + }, + { + "id": 15, + "created_at": "2026-03-11T19:53:15Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 132000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 70000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 27000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T19:53:15.025172Z" + } + } + ] + } + }, + { + "id": 16, + "created_at": "2026-03-11T19:53:15Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T19:53:15.107154Z" + } + } + ] + } + }, + { + "id": 17, + "created_at": "2026-03-11T19:53:16Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-3", + "extra": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:15.124887Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"CHECK_STATUS\",\"providerTrxId\":\"test-provider-trx-3\",\"pollingInfo\":{\"start_date_time_polling\":1773258796.149837641,\"max_date_time_polling\":1773262396.149837641}}" + } + } + } + }, + "occurred_at": "2026-03-11T19:53:15.124887Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-13" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:15.124887Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_suspended": { + "tag": "FLEXIFY-2EloA78BbF2-1", + "timeout_behaviour": { + "callback": "{\"tag\":\"2EloA78BbF2-1\"}" + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:15.124887Z" + } + } + ] + } + }, + { + "id": 18, + "created_at": "2026-03-11T19:53:50Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_activated": [] + } + } + }, + "occurred_at": "2026-03-11T19:53:50.335871Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-14" + } + } + }, + "status": { + "completed": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.335871Z" + } + } + ] + } + }, + { + "id": 19, + "created_at": "2026-03-11T19:53:50Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-3", + "extra": [], + "additional_info": { + "rrn": "2EloA78BbF2-1", + "extra_payment_info": { + "currency": "EUR" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.395089Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-3\"}" + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.395089Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.395089Z" + } + } + ] + } + }, + { + "id": 20, + "created_at": "2026-03-11T19:53:50Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "processed": [] + } + } + }, + "occurred_at": "2026-03-11T19:53:50.553133Z" + } + } + ] + } + }, + { + "id": 21, + "created_at": "2026-03-11T19:53:50Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_capture_started": { + "data": { + "reason": "Timeout", + "cash": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.569939Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T19:53:50.569939Z" + } + } + ] + } + }, + { + "id": 22, + "created_at": "2026-03-11T19:53:50Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-3", + "extra": { + "kzt_to_eur_rate": "567.99", + "kzt_to_eur_converted_amount": "1761" + }, + "additional_info": { + "rrn": "2EloA78BbF2-1", + "extra_payment_info": { + "kzt_to_eur_rate": "567.99", + "kzt_to_eur_converted_amount": "1761" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.582392Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-3\"}" + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.582392Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.582392Z" + } + } + ] + } + }, + { + "id": 23, + "created_at": "2026-03-11T19:53:50Z", + "source": { + "invoice_id": "2EloA78BbF2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 1000000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + } + }, + "occurred_at": "2026-03-11T19:53:50.633584Z" + } + }, + { + "invoice_status_changed": { + "status": { + "paid": [] + } + } + } + ] + } + } +] diff --git a/src/test/resources/payments/2ElsBI5GY4m.txt b/src/test/resources/payments/2ElsBI5GY4m.txt new file mode 100644 index 0000000..9b95e31 --- /dev/null +++ b/src/test/resources/payments/2ElsBI5GY4m.txt @@ -0,0 +1,2063 @@ +2026-03-13T15:32:04.732814+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:32:04.736413+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T15:32:04.736901+00:00 notice: Skipping unknown field [5] with type: struct +[ + { + "id": 1, + "created_at": "2026-03-11T20:49:25Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2ElsBI5GY4m", + "domain_revision": 2901, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "created_at": "2026-03-11T20:49:25.892772Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "371797540" + }, + "due": "2026-03-11T21:49:25Z", + "cost": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-eac0e359e4946f2c68594b0e93004276-593c7c82d90fcc34-01\",\"transaction_id\":\"test-uuid-10\",\"user_id\":\"50400\"}" + }, + "external_id": "test-external-9" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2026-03-11T20:49:26Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2026-03-11T20:49:26.305189Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-uuid-10\",\"user_id\":\"50400\"}" + }, + "cost": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 2901, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "MASTERCARD" + }, + "bin": "411111", + "last_digits": "5288", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 6, + "year": 2026 + }, + "category": "PLATINUM" + } + }, + "payment_session_id": "5FKIWL4OBRQtGl5kYcNPys", + "client_info": { + "ip_address": "198.51.100.22", + "fingerprint": "74bd837bd785493cab84d1e67a3a493e", + "peer_ip_address": "198.51.100.23", + "user_ip_address": "198.51.100.24" + } + }, + "contact_info": { + "phone_number": "+70000000005", + "email": "user5@example.test", + "first_name": "Test", + "last_name": "UserFive", + "country": "KAZ", + "state": "Kazakhstan", + "city": "Алматы" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-15" + }, + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + }, + "make_recurrent": false, + "external_id": "test-external-10", + "processing_deadline": "2026-03-11T20:51:26.278Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2026-03-11T20:49:26.305189Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2026-03-11T20:49:26Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2026-03-11T20:49:26.334389Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2026-03-11T20:49:26Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2026-03-11T20:49:26.350789Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2026-03-11T20:49:26Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "low" + } + }, + "occurred_at": "2026-03-11T20:49:26.369959Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2026-03-11T20:49:27Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "candidates": [ + { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 1800000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 1800000 + } + ] + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-11T20:49:26.622310Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2026-03-11T20:49:27Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 96000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 60000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 19800, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T20:49:27.438611Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2026-03-11T20:49:27Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T20:49:27.543732Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2026-03-11T20:49:27Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "100000000004", + "extra": { + "kzt_to_usd_rate": "489.39", + "kzt_to_usd_converted_amount": "1635" + } + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:27.560127Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30500' - 'code: 30003 reason: no appropriate payment routes found'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:27.560127Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30500' - 'code: 30003 reason: no appropriate payment routes found'", + "sub": { + "code": "unknown" + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:27.560127Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2026-03-11T20:49:28Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "candidates": [ + { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 1800000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 1800000 + } + ] + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-11T20:49:27.722089Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2026-03-11T20:49:28Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 96000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "volume": { + "amount": 40000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T20:49:28.396396Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2026-03-11T20:49:28Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T20:49:28.469010Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2026-03-11T20:49:33Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "2ElsBI5GY4m.1", + "extra": { + "term_url": "https://example.com/test-url-16" + } + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:33.470487Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:33.470487Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:33.470487Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2026-03-11T20:49:34Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "candidates": [ + { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 5 + }, + "terminal": { + "id": 12 + } + }, + "value": [ + { + "limit": { + "ref": { + "id": "payment_card_day_amount_kzt" + }, + "upper_boundary": 1180000000, + "domain_revision": 1523 + }, + "value": 1800000 + }, + { + "limit": { + "ref": { + "id": "payment_card_month_amount_kzt" + }, + "upper_boundary": 2953000000, + "domain_revision": 1523 + }, + "value": 1800000 + } + ] + } + ] + } + }, + "occurred_at": "2026-03-11T20:49:33.845927Z" + } + } + ] + } + }, + { + "id": 15, + "created_at": "2026-03-11T20:49:34Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 111000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-uuid-1" + }, + "shop_ref": { + "id": "test-uuid-2" + } + } + } + } + }, + "volume": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 56000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 70801, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 5 + }, + "terminal_ref": { + "id": 12 + } + } + } + } + }, + "volume": { + "amount": 27000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-11T20:49:34.515251Z" + } + } + ] + } + }, + { + "id": 16, + "created_at": "2026-03-11T20:49:34Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T20:49:34.568597Z" + } + } + ] + } + }, + { + "id": 17, + "created_at": "2026-03-11T20:49:35Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-4", + "extra": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:34.584505Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"CHECK_STATUS\",\"providerTrxId\":\"test-provider-trx-4\",\"pollingInfo\":{\"start_date_time_polling\":1773262175.699550299,\"max_date_time_polling\":1773265775.699550299}}" + } + } + } + }, + "occurred_at": "2026-03-11T20:49:34.584505Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-17" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:34.584505Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_suspended": { + "tag": "FLEXIFY-2ElsBI5GY4m-1", + "timeout_behaviour": { + "callback": "{\"tag\":\"2ElsBI5GY4m-1\"}" + } + } + } + } + }, + "occurred_at": "2026-03-11T20:49:34.584505Z" + } + } + ] + } + }, + { + "id": 18, + "created_at": "2026-03-11T20:50:04Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_activated": [] + } + } + }, + "occurred_at": "2026-03-11T20:50:04.354734Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-18" + } + } + }, + "status": { + "completed": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.354734Z" + } + } + ] + } + }, + { + "id": 19, + "created_at": "2026-03-11T20:50:04Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-4", + "extra": [], + "additional_info": { + "rrn": "2ElsBI5GY4m-1", + "extra_payment_info": { + "currency": "EUR" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.412229Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-4\"}" + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.412229Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.412229Z" + } + } + ] + } + }, + { + "id": 20, + "created_at": "2026-03-11T20:50:04Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "processed": [] + } + } + }, + "occurred_at": "2026-03-11T20:50:04.564375Z" + } + } + ] + } + }, + { + "id": 21, + "created_at": "2026-03-11T20:50:04Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_capture_started": { + "data": { + "reason": "Timeout", + "cash": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.578090Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-11T20:50:04.578090Z" + } + } + ] + } + }, + { + "id": 22, + "created_at": "2026-03-11T20:50:04Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-4", + "extra": { + "kzt_to_eur_rate": "567.99", + "kzt_to_eur_converted_amount": "1408" + }, + "additional_info": { + "rrn": "2ElsBI5GY4m-1", + "extra_payment_info": { + "kzt_to_eur_rate": "567.99", + "kzt_to_eur_converted_amount": "1408" + } + } + } + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.594202Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-4\"}" + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.594202Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.594202Z" + } + } + ] + } + }, + { + "id": 23, + "created_at": "2026-03-11T20:50:04Z", + "source": { + "invoice_id": "2ElsBI5GY4m" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + } + }, + "occurred_at": "2026-03-11T20:50:04.663152Z" + } + }, + { + "invoice_status_changed": { + "status": { + "paid": [] + } + } + } + ] + } + } +] diff --git a/src/test/resources/payments/2EnbPdxImPo_events.txt b/src/test/resources/payments/2EnbPdxImPo_events.txt new file mode 100644 index 0000000..97e03d8 --- /dev/null +++ b/src/test/resources/payments/2EnbPdxImPo_events.txt @@ -0,0 +1,1817 @@ +2026-03-13T12:07:40.464189+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T12:07:40.470934+00:00 notice: Skipping unknown field [5] with type: struct +2026-03-13T12:07:40.471256+00:00 notice: Skipping unknown field [5] with type: struct +[ + { + "id": 1, + "created_at": "2026-03-12T21:49:59Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2EnbPdxImPo", + "domain_revision": 2999, + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + }, + "created_at": "2026-03-12T21:49:59.408094Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "test-external-1" + }, + "due": "2026-03-12T22:49:59Z", + "cost": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-00000000000000000000000000000000-0000000000000000-01\",\"transaction_id\":\"test-transaction-1\",\"user_id\":\"test-user-1\"}" + }, + "external_id": "test-external-1" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2026-03-12T21:49:59Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2026-03-12T21:49:59.901367Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-transaction-1\",\"user_id\":\"test-user-1\"}" + }, + "cost": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 2999, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "VISA" + }, + "bin": "411111", + "last_digits": "1111", + "issuer_country": "KAZ", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 10, + "year": 2029 + }, + "category": "REWARDS" + } + }, + "payment_session_id": "test-payment-session-1", + "client_info": { + "ip_address": "198.51.100.25", + "fingerprint": "test-fingerprint-1", + "peer_ip_address": "198.51.100.26", + "user_ip_address": "198.51.100.27" + } + }, + "contact_info": { + "phone_number": "+70000000006", + "email": "user6@example.test", + "country": "KAZ", + "state": "Kazakhstan" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-19" + }, + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + }, + "make_recurrent": false, + "external_id": "test-external-1", + "processing_deadline": "2026-03-12T21:51:59.874Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2026-03-12T21:49:59.901367Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2026-03-12T21:49:59Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2026-03-12T21:49:59.926987Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2026-03-12T21:49:59Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2026-03-12T21:49:59.944485Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2026-03-12T21:50:00Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "low" + } + }, + "occurred_at": "2026-03-12T21:49:59.962070Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2026-03-12T21:50:00Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "candidates": [ + { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2550 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-12T21:50:00.215040Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2026-03-12T21:50:00Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 60375, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "volume": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 31875, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2550 + } + } + } + } + }, + "volume": { + "amount": 19800, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-12T21:50:00.413382Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2026-03-12T21:50:00Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-12T21:50:00.469754Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2026-03-12T21:50:25Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-2", + "extra": { + "kzt_to_usd_rate": "491.68", + "kzt_to_usd_converted_amount": "864" + } + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:00.485347Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30500' - 'code: 30600 reason: Value 'test-card-mask-1' is invalid. The combination of currency, card type and transaction type is not supported by a Merchant Acquirer relationship'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:00.485347Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "preauthorization_failed", + "reason": "'30500' - 'code: 30600 reason: Value 'test-card-mask-1' is invalid. The combination of currency, card type and transaction type is not supported by a Merchant Acquirer relationship'", + "sub": { + "code": "unknown" + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:00.485347Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2026-03-12T21:50:25Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "candidates": [ + { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4, + "route_pin": 0, + "random_condition": 50, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 4 + }, + "terminal": { + "id": 2554 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-12T21:50:25.657993Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2026-03-12T21:50:25Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 60375, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "volume": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 68555, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 4 + }, + "terminal_ref": { + "id": 2554 + } + } + } + } + }, + "volume": { + "amount": 21250, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-12T21:50:25.845803Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2026-03-12T21:50:25Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-12T21:50:25.898434Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2026-03-12T21:50:26Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "2EnbPdxImPo.1", + "extra": { + "term_url": "https://example.com/test-url-20" + } + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:25.916636Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:25.916636Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'resp_status_error' - 'Daily limit is exceeded.'", + "sub": { + "code": "account_limit_exceeded" + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:25.916636Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2026-03-12T21:50:26Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "candidates": [ + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2551 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2026-03-12T21:50:26.290840Z" + } + } + ] + } + }, + { + "id": 15, + "created_at": "2026-03-12T21:50:26Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 71625, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2551 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 5097, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_ref": { + "id": "test-party-1" + }, + "shop_ref": { + "id": "test-shop-1" + } + } + } + } + }, + "volume": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2551 + } + } + } + } + }, + "volume": { + "amount": 31875, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 5011, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 12204, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 254 + }, + "terminal_ref": { + "id": 2551 + } + } + } + } + }, + "volume": { + "amount": 20400, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2026-03-12T21:50:26.392986Z" + } + } + ] + } + }, + { + "id": 16, + "created_at": "2026-03-12T21:50:26Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-12T21:50:26.440170Z" + } + } + ] + } + }, + { + "id": 17, + "created_at": "2026-03-12T21:50:30Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-1", + "extra": { + "kzt_to_eur_rate": "568.23", + "kzt_to_eur_converted_amount": "748" + } + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:26.455724Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"CHECK_STATUS\",\"providerTrxId\":\"test-provider-trx-1\"}" + } + } + } + }, + "occurred_at": "2026-03-12T21:50:26.455724Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-21" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:26.455724Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_suspended": { + "tag": "transferty_2551.2EnbPdxImPo.1", + "timeout_behaviour": { + "callback": "" + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:26.455724Z" + } + } + ] + } + }, + { + "id": 18, + "created_at": "2026-03-12T21:50:39Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_activated": [] + } + } + }, + "occurred_at": "2026-03-12T21:50:39.225159Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-22" + } + } + }, + "status": { + "completed": [] + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:39.225159Z" + } + } + ] + } + }, + { + "id": 19, + "created_at": "2026-03-12T21:50:39Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-1\"}" + } + } + } + }, + "occurred_at": "2026-03-12T21:50:39.277658Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:39.277658Z" + } + } + ] + } + }, + { + "id": 20, + "created_at": "2026-03-12T21:50:39Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "processed": [] + } + } + }, + "occurred_at": "2026-03-12T21:50:39.356130Z" + } + } + ] + } + }, + { + "id": 21, + "created_at": "2026-03-12T21:50:39Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_capture_started": { + "data": { + "reason": "Timeout", + "cash": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:39.373515Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2026-03-12T21:50:39.373515Z" + } + } + ] + } + }, + { + "id": 22, + "created_at": "2026-03-12T21:50:39Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"DO_NOTHING\",\"providerTrxId\":\"test-provider-trx-1\"}" + } + } + } + }, + "occurred_at": "2026-03-12T21:50:39.391316Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:39.391316Z" + } + } + ] + } + }, + { + "id": 23, + "created_at": "2026-03-12T21:50:39Z", + "source": { + "invoice_id": "2EnbPdxImPo" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 425000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + } + }, + "occurred_at": "2026-03-12T21:50:39.440842Z" + } + }, + { + "invoice_status_changed": { + "status": { + "paid": [] + } + } + } + ] + } + } +] diff --git a/src/test/resources/payments/response (1).txt b/src/test/resources/payments/response (1).txt new file mode 100644 index 0000000..065317f --- /dev/null +++ b/src/test/resources/payments/response (1).txt @@ -0,0 +1,648 @@ +[ + { + "id": 1, + "created_at": "2025-11-19T11:38:02.757064Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2BlLXfYbPjU", + "owner_id": "test-party-1", + "party_revision": 175, + "shop_id": "test-shop-1", + "created_at": "2025-11-19T11:38:02.732914Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "test", + "cart": { + "lines": [ + { + "product": "test", + "quantity": 1, + "price": { + "amount": 100000, + "currency": { + "symbolic_code": "KZT" + } + }, + "metadata": [] + } + ] + } + }, + "due": "2025-12-19T11:38:02Z", + "cost": { + "amount": 100000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{}" + }, + "template_id": "2Bi4VLcbJtA" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2025-11-19T11:38:03.406131Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2025-11-19T11:38:03.398755Z", + "status": { + "pending": [] + }, + "cost": { + "amount": 100000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 72933, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "payment_terminal": { + "payment_service": { + "id": "Bankcard on provider side" + }, + "metadata": { + "dev.vality.paymentResource": { + "obj": [] + } + } + } + }, + "payment_session_id": "test-payment-session-1", + "client_info": { + "ip_address": "198.51.100.28", + "fingerprint": "test-fingerprint-1", + "peer_ip_address": "198.51.100.29" + } + }, + "contact_info": { + "email": "user7@example.test" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-23" + }, + "party_revision": 175, + "owner_id": "test-party-1", + "shop_id": "test-shop-1", + "make_recurrent": false, + "processing_deadline": "2025-11-19T11:40:03.370Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2025-11-19T11:38:03.398755Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2025-11-19T11:38:03.420620Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2025-11-19T11:38:03.419501Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2025-11-19T11:38:03.434770Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2025-11-19T11:38:03.433841Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2025-11-19T11:38:03.463510Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "high" + } + }, + "occurred_at": "2025-11-19T11:38:03.448609Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2025-11-19T11:38:03.530473Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 411 + }, + "terminal": { + "id": 2528 + } + }, + "candidates": [ + { + "provider": { + "id": 411 + }, + "terminal": { + "id": 2528 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 411 + }, + "terminal": { + "id": 2528 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1000, + "route_pin": 0, + "random_condition": 0, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 411 + }, + "terminal": { + "id": 2528 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2025-11-19T11:38:03.476594Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2025-11-19T11:38:03.604258Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 85432495, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-1", + "shop_id": "test-shop-1" + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 3900, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 85258334, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 411 + }, + "terminal_ref": { + "id": 2528 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 85432495, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-1", + "shop_id": "test-shop-1" + } + } + } + }, + "volume": { + "amount": 100000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 85432495, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-1", + "shop_id": "test-shop-1" + } + } + } + }, + "volume": { + "amount": 1000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2025-11-19T11:38:03.545896Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2025-11-19T11:38:03.623715Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2025-11-19T11:38:03.622767Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2025-11-19T11:38:04.565157Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-1", + "extra": [] + } + } + } + } + }, + "occurred_at": "2025-11-19T11:38:03.638720Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"CHECK_STATUS\",\"providerTrxId\":\"test-provider-trx-1\",\"pollingInfo\":{\"start_date_time_polling\":1763552284.476170378,\"max_date_time_polling\":1763554084.476170378}}" + } + } + } + }, + "occurred_at": "2025-11-19T11:38:03.638720Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-24" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-19T11:38:03.638720Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_suspended": { + "tag": "oldmoney-apm2BlLXfYbPjU.1", + "timeout_behaviour": { + "callback": "{\"tag\":\"2BlLXfYbPjU.1\"}" + } + } + } + } + }, + "occurred_at": "2025-11-19T11:38:03.638720Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2025-11-19T11:39:04.126326Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_activated": [] + } + } + }, + "occurred_at": "2025-11-19T11:39:04.104667Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2025-11-19T11:40:08.247744Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'FAILED: REFUSED' - 'Transaction failed'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2025-11-19T11:40:08.109543Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'FAILED: REFUSED' - 'Transaction failed'", + "sub": { + "code": "unknown" + } + } + } + } + }, + "occurred_at": "2025-11-19T11:40:08.109543Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2025-11-19T11:40:08.299436Z", + "source": { + "invoice_id": "test-invoice-1" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'FAILED: REFUSED' - 'Transaction failed'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + }, + "occurred_at": "2025-11-19T11:40:08.262768Z" + } + } + ] + } + } +] diff --git a/src/test/resources/payments/response (2).txt b/src/test/resources/payments/response (2).txt new file mode 100644 index 0000000..39e72eb --- /dev/null +++ b/src/test/resources/payments/response (2).txt @@ -0,0 +1,648 @@ +[ + { + "id": 1, + "created_at": "2025-11-19T11:45:16.386225Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2BlM3gwUhH6", + "owner_id": "test-party-1", + "party_revision": 175, + "shop_id": "test-shop-1", + "created_at": "2025-11-19T11:45:16.363789Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "test", + "cart": { + "lines": [ + { + "product": "test", + "quantity": 1, + "price": { + "amount": 100000, + "currency": { + "symbolic_code": "KZT" + } + }, + "metadata": [] + } + ] + } + }, + "due": "2025-12-19T11:45:16Z", + "cost": { + "amount": 100000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{}" + }, + "template_id": "2Bi4VLcbJtA" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2025-11-19T11:45:17.036501Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2025-11-19T11:45:17.028132Z", + "status": { + "pending": [] + }, + "cost": { + "amount": 100000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 72933, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "payment_terminal": { + "payment_service": { + "id": "Bankcard on provider side" + }, + "metadata": { + "dev.vality.paymentResource": { + "obj": [] + } + } + } + }, + "payment_session_id": "test-payment-session-2", + "client_info": { + "ip_address": "198.51.100.30", + "fingerprint": "test-fingerprint-2", + "peer_ip_address": "198.51.100.31" + } + }, + "contact_info": { + "email": "user8@example.test" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-25" + }, + "party_revision": 175, + "owner_id": "test-party-1", + "shop_id": "test-shop-1", + "make_recurrent": false, + "processing_deadline": "2025-11-19T11:47:17.000Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2025-11-19T11:45:17.028132Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2025-11-19T11:45:17.053286Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2025-11-19T11:45:17.052352Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2025-11-19T11:45:17.071278Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2025-11-19T11:45:17.070365Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2025-11-19T11:45:17.096738Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "high" + } + }, + "occurred_at": "2025-11-19T11:45:17.087274Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2025-11-19T11:45:17.195824Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 411 + }, + "terminal": { + "id": 2528 + } + }, + "candidates": [ + { + "provider": { + "id": 411 + }, + "terminal": { + "id": 2528 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 411 + }, + "terminal": { + "id": 2528 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1000, + "route_pin": 0, + "random_condition": 0, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 411 + }, + "terminal": { + "id": 2528 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2025-11-19T11:45:17.152147Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2025-11-19T11:45:17.264008Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 85432495, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-1", + "shop_id": "test-shop-1" + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 3900, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 85258334, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 411 + }, + "terminal_ref": { + "id": 2528 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 85432495, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-1", + "shop_id": "test-shop-1" + } + } + } + }, + "volume": { + "amount": 100000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 85432495, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-1", + "shop_id": "test-shop-1" + } + } + } + }, + "volume": { + "amount": 1000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2025-11-19T11:45:17.211718Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2025-11-19T11:45:17.283700Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2025-11-19T11:45:17.282706Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2025-11-19T11:45:17.537818Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "test-provider-trx-2", + "extra": [] + } + } + } + } + }, + "occurred_at": "2025-11-19T11:45:17.297467Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"nextStep\":\"CHECK_STATUS\",\"providerTrxId\":\"test-provider-trx-2\",\"pollingInfo\":{\"start_date_time_polling\":1763552717.491631730,\"max_date_time_polling\":1763554517.491631730}}" + } + } + } + }, + "occurred_at": "2025-11-19T11:45:17.297467Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-26" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-19T11:45:17.297467Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_suspended": { + "tag": "oldmoney-apm2BlM3gwUhH6.1", + "timeout_behaviour": { + "callback": "{\"tag\":\"2BlM3gwUhH6.1\"}" + } + } + } + } + }, + "occurred_at": "2025-11-19T11:45:17.297467Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2025-11-19T11:45:47.271623Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_activated": [] + } + } + }, + "occurred_at": "2025-11-19T11:45:47.253643Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2025-11-19T11:45:47.390040Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'FAILED: REFUSED' - 'Transaction failed'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2025-11-19T11:45:47.310097Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'FAILED: REFUSED' - 'Transaction failed'", + "sub": { + "code": "unknown" + } + } + } + } + }, + "occurred_at": "2025-11-19T11:45:47.310097Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2025-11-19T11:45:47.430646Z", + "source": { + "invoice_id": "test-invoice-2" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'FAILED: REFUSED' - 'Transaction failed'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + }, + "occurred_at": "2025-11-19T11:45:47.406035Z" + } + } + ] + } + } +] diff --git a/src/test/resources/payments/response (3).txt b/src/test/resources/payments/response (3).txt new file mode 100644 index 0000000..5c8b199 --- /dev/null +++ b/src/test/resources/payments/response (3).txt @@ -0,0 +1,804 @@ +[ + { + "id": 1, + "created_at": "2025-11-13T12:56:05.114034Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2BbThBKxCts", + "owner_id": "test-party-1", + "party_revision": 174, + "shop_id": "test-shop-2", + "created_at": "2025-11-13T12:56:05.092898Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "test", + "cart": { + "lines": [ + { + "product": "test", + "quantity": 1, + "price": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + }, + "metadata": [] + } + ] + } + }, + "due": "2025-12-13T12:56:05Z", + "cost": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{}" + }, + "template_id": "2BbT4f7zYlk" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2025-11-13T12:56:05.902466Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2025-11-13T12:56:05.895242Z", + "status": { + "pending": [] + }, + "cost": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 72879, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "VISA" + }, + "bin": "411111", + "last_digits": "1111", + "issuer_country": "KAZ", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 2, + "year": 2026 + }, + "cardholder_name": "TEST CARDHOLDER", + "category": "REWARDS" + } + }, + "payment_session_id": "test-payment-session-3", + "client_info": { + "ip_address": "198.51.100.32", + "fingerprint": "test-fingerprint-3", + "peer_ip_address": "198.51.100.33" + } + }, + "contact_info": { + "email": "user9@example.test" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-27" + }, + "party_revision": 174, + "owner_id": "test-party-1", + "shop_id": "test-shop-2", + "make_recurrent": true, + "processing_deadline": "2025-11-13T12:58:05.869Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2025-11-13T12:56:05.895242Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2025-11-13T12:56:05.917754Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2025-11-13T12:56:05.917147Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2025-11-13T12:56:05.929847Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2025-11-13T12:56:05.929245Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2025-11-13T12:56:05.951766Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "high" + } + }, + "occurred_at": "2025-11-13T12:56:05.943237Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2025-11-13T12:56:06.014047Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "candidates": [ + { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1000, + "route_pin": 0, + "random_condition": 0, + "availability": 1.00000000000000000000e+00, + "conversion": 9.89193127314674236494e-01, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2025-11-13T12:56:05.967358Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2025-11-13T12:56:06.067519Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 13047173, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-1", + "shop_id": "test-shop-2" + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 390, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 329, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 170 + }, + "terminal_ref": { + "id": 2529 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 13047173, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-1", + "shop_id": "test-shop-2" + } + } + } + }, + "volume": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 329, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 170 + }, + "terminal_ref": { + "id": 2529 + } + } + } + } + }, + "volume": { + "amount": 420, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2025-11-13T12:56:06.028774Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2025-11-13T12:56:06.081304Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2025-11-13T12:56:06.080620Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2025-11-13T12:56:06.239857Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "GE00000000000001", + "extra": [] + } + } + } + } + }, + "occurred_at": "2025-11-13T12:56:06.096762Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"trxId\":\"test-provider-trx-alt-1\",\"step\":\"CHECK_STATUS\",\"options\":{},\"start_date_time_polling\":1763038566.231196177,\"max_date_time_polling\":1763039166.231195619}" + } + } + } + }, + "occurred_at": "2025-11-13T12:56:06.096762Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2025-11-13T12:56:18.972176Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-28" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-13T12:56:18.926463Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2025-11-13T12:56:38.977157Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-29" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-13T12:56:38.925879Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2025-11-13T12:56:58.978493Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rec_token_acquired": { + "token": "test-token" + } + }, + "occurred_at": "2025-11-13T12:56:58.926585Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2025-11-13T12:56:58.926585Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2025-11-13T12:56:58.991834Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "processed": [] + } + } + }, + "occurred_at": "2025-11-13T12:56:58.990467Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2025-11-13T12:56:59.004060Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_capture_started": { + "data": { + "reason": "Timeout", + "cash": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + }, + "occurred_at": "2025-11-13T12:56:59.002995Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2025-11-13T12:56:59.002995Z" + } + } + ] + } + }, + { + "id": 15, + "created_at": "2025-11-13T12:56:59.036362Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"trxId\":\"test-provider-trx-alt-1\",\"step\":\"CAPTURE\",\"options\":{}}" + } + } + } + }, + "occurred_at": "2025-11-13T12:56:59.022064Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2025-11-13T12:56:59.022064Z" + } + } + ] + } + }, + { + "id": 16, + "created_at": "2025-11-13T12:56:59.078496Z", + "source": { + "invoice_id": "test-invoice-3" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + } + }, + "occurred_at": "2025-11-13T12:56:59.051779Z" + } + }, + { + "invoice_status_changed": { + "status": { + "paid": [] + } + } + } + ] + } + } +] diff --git a/src/test/resources/payments/response (4).txt b/src/test/resources/payments/response (4).txt new file mode 100644 index 0000000..a00878b --- /dev/null +++ b/src/test/resources/payments/response (4).txt @@ -0,0 +1,798 @@ +[ + { + "id": 1, + "created_at": "2025-11-13T19:21:42.710810Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2BbvFjORLQO", + "owner_id": "test-party-2", + "party_revision": 112, + "shop_id": "test-shop-3", + "created_at": "2025-11-13T19:21:42.692396Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "125840057" + }, + "due": "2025-11-14T19:21:42Z", + "cost": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-00000000000000000000000000000000-0000000000000001-01\",\"transaction_id\":\"test-transaction-embedded\",\"user_id\":\"test-user-embedded\"}" + }, + "external_id": "test-external-1" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2025-11-13T19:21:43.067733Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2025-11-13T19:21:43.060593Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-transaction-embedded\",\"user_id\":\"test-user-embedded\"}" + }, + "cost": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 72884, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "MASTERCARD" + }, + "bin": "411111", + "last_digits": "1111", + "issuer_country": "KAZ", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 11, + "year": 2029 + }, + "category": "PLATINUM" + } + }, + "payment_session_id": "test-payment-session-4", + "client_info": { + "ip_address": "198.51.100.34", + "fingerprint": "test-fingerprint-4", + "peer_ip_address": "198.51.100.35", + "user_ip_address": "198.51.100.36" + } + }, + "contact_info": { + "email": "user10@example.test", + "country": "KAZ", + "state": "Kazakhstan" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-30" + }, + "party_revision": 112, + "owner_id": "test-party-2", + "shop_id": "test-shop-3", + "make_recurrent": true, + "external_id": "test-external-2", + "processing_deadline": "2025-11-13T19:23:43.036Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2025-11-13T19:21:43.060593Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2025-11-13T19:21:43.084186Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2025-11-13T19:21:43.083069Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2025-11-13T19:21:43.100955Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2025-11-13T19:21:43.099811Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2025-11-13T19:21:43.170360Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "high" + } + }, + "occurred_at": "2025-11-13T19:21:43.117696Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2025-11-13T19:21:43.276689Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "candidates": [ + { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2171 + } + }, + { + "provider": { + "id": 275 + }, + "terminal": { + "id": 2304 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 275 + }, + "terminal": { + "id": 2304 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 3000, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2171 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2000, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4000, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 9.89431333693628789483e-01, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 275 + }, + "terminal": { + "id": 2304 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2171 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2025-11-13T19:21:43.184055Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2025-11-13T19:21:43.336665Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 82657505, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-4", + "shop_id": "test-shop-3" + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 9000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 329, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 170 + }, + "terminal_ref": { + "id": 2529 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 82657505, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-4", + "shop_id": "test-shop-3" + } + } + } + }, + "volume": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 329, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 170 + }, + "terminal_ref": { + "id": 2529 + } + } + } + } + }, + "volume": { + "amount": 8400, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2025-11-13T19:21:43.291110Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2025-11-13T19:21:43.397769Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2025-11-13T19:21:43.396637Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2025-11-13T19:21:43.670337Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "GE00000000000002", + "extra": [] + } + } + } + } + }, + "occurred_at": "2025-11-13T19:21:43.413913Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"trxId\":\"test-provider-trx-alt-1\",\"step\":\"CHECK_STATUS\",\"options\":{},\"start_date_time_polling\":1763061703.659974862,\"max_date_time_polling\":1763062303.659974369}" + } + } + } + }, + "occurred_at": "2025-11-13T19:21:43.413913Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2025-11-13T19:21:51.155258Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rec_token_acquired": { + "token": "test-token" + } + }, + "occurred_at": "2025-11-13T19:21:51.108891Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2025-11-13T19:21:51.108891Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2025-11-13T19:21:51.170546Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "processed": [] + } + } + }, + "occurred_at": "2025-11-13T19:21:51.169538Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2025-11-13T19:21:51.184608Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_capture_started": { + "data": { + "reason": "Timeout", + "cash": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + }, + "occurred_at": "2025-11-13T19:21:51.183807Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2025-11-13T19:21:51.183807Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2025-11-13T19:21:51.223656Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"trxId\":\"test-provider-trx-alt-1\",\"step\":\"CAPTURE\",\"options\":{}}" + } + } + } + }, + "occurred_at": "2025-11-13T19:21:51.199171Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + } + } + }, + "payload": { + "session_finished": { + "result": { + "succeeded": [] + } + } + } + } + }, + "occurred_at": "2025-11-13T19:21:51.199171Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2025-11-13T19:21:51.267100Z", + "source": { + "invoice_id": "test-invoice-4" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "captured": { + "reason": "Timeout", + "cost": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + } + } + } + } + }, + "occurred_at": "2025-11-13T19:21:51.237551Z" + } + }, + { + "invoice_status_changed": { + "status": { + "paid": [] + } + } + } + ] + } + } +] diff --git a/src/test/resources/payments/response (5).txt b/src/test/resources/payments/response (5).txt new file mode 100644 index 0000000..9b55499 --- /dev/null +++ b/src/test/resources/payments/response (5).txt @@ -0,0 +1,1768 @@ +[ + { + "id": 1, + "created_at": "2025-11-14T10:24:28.719864Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2BcxlVzvnqy", + "owner_id": "test-party-3", + "party_revision": 51, + "shop_id": "test-shop-4", + "created_at": "2025-11-14T10:24:28.698271Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "1028623" + }, + "due": "2025-11-15T10:24:28Z", + "cost": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-00000000000000000000000000000000-0000000000000002-01\",\"transaction_id\":\"test-transaction-embedded\",\"user_id\":\"test-user-embedded\"}" + }, + "external_id": "test-external-3" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2025-11-14T10:24:29.122465Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2025-11-14T10:24:29.114953Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-transaction-embedded\",\"user_id\":\"test-user-embedded\"}" + }, + "cost": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 72891, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "VISA" + }, + "bin": "411111", + "last_digits": "1111", + "issuer_country": "KAZ", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 2, + "year": 2026 + }, + "category": "REWARDS" + } + }, + "payment_session_id": "test-payment-session-5", + "client_info": { + "ip_address": "198.51.100.37", + "fingerprint": "test-fingerprint-5", + "peer_ip_address": "198.51.100.38", + "user_ip_address": "198.51.100.39" + } + }, + "contact_info": { + "email": "user11@example.test", + "country": "KAZ", + "state": "Kazakhstan" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-31" + }, + "party_revision": 51, + "owner_id": "test-party-3", + "shop_id": "test-shop-4", + "make_recurrent": true, + "external_id": "test-external-4", + "processing_deadline": "2025-11-14T10:26:29.091Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2025-11-14T10:24:29.114953Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2025-11-14T10:24:29.136483Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2025-11-14T10:24:29.135286Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2025-11-14T10:24:29.151770Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2025-11-14T10:24:29.150568Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2025-11-14T10:24:29.186674Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "high" + } + }, + "occurred_at": "2025-11-14T10:24:29.172720Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2025-11-14T10:24:29.245128Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "candidates": [ + { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 1000, + "route_pin": 0, + "random_condition": 0, + "availability": 1.00000000000000000000e+00, + "conversion": 9.63086637119786259653e-01, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2025-11-14T10:24:29.200424Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2025-11-14T10:24:29.307742Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 85723906, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-6", + "shop_id": "test-shop-4" + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 333, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 390, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 329, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 170 + }, + "terminal_ref": { + "id": 2529 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 85723906, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-6", + "shop_id": "test-shop-4" + } + } + } + }, + "volume": { + "amount": 10000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 333, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 329, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 170 + }, + "terminal_ref": { + "id": 2529 + } + } + } + } + }, + "volume": { + "amount": 420, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2025-11-14T10:24:29.259479Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2025-11-14T10:24:29.322318Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2025-11-14T10:24:29.321401Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2025-11-14T10:24:29.495195Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "GE00000000000003", + "extra": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:24:29.338079Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"trxId\":\"test-provider-trx-alt-1\",\"step\":\"CHECK_STATUS\",\"options\":{},\"start_date_time_polling\":1763115869.482891063,\"max_date_time_polling\":1763116469.482890530}" + } + } + } + }, + "occurred_at": "2025-11-14T10:24:29.338079Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2025-11-14T10:24:37.155699Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-32" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:24:37.104385Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2025-11-14T10:24:57.157103Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-33" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:24:57.104830Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2025-11-14T10:25:17.155134Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-34" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:25:17.115029Z" + } + } + ] + } + }, + { + "id": 13, + "created_at": "2025-11-14T10:25:37.160204Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-35" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:25:37.109052Z" + } + } + ] + } + }, + { + "id": 14, + "created_at": "2025-11-14T10:25:57.152078Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-36" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:25:57.104418Z" + } + } + ] + } + }, + { + "id": 15, + "created_at": "2025-11-14T10:26:17.151927Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-37" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:26:17.109318Z" + } + } + ] + } + }, + { + "id": 16, + "created_at": "2025-11-14T10:26:37.154330Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-38" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:26:37.109036Z" + } + } + ] + } + }, + { + "id": 17, + "created_at": "2025-11-14T10:26:57.160146Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-39" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:26:57.107185Z" + } + } + ] + } + }, + { + "id": 18, + "created_at": "2025-11-14T10:27:17.151623Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-40" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:27:17.107678Z" + } + } + ] + } + }, + { + "id": 19, + "created_at": "2025-11-14T10:27:37.150250Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-41" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:27:37.111268Z" + } + } + ] + } + }, + { + "id": 20, + "created_at": "2025-11-14T10:27:57.175002Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-42" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:27:57.112052Z" + } + } + ] + } + }, + { + "id": 21, + "created_at": "2025-11-14T10:28:17.167396Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-43" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:28:17.111609Z" + } + } + ] + } + }, + { + "id": 22, + "created_at": "2025-11-14T10:28:37.163980Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-44" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:28:37.111522Z" + } + } + ] + } + }, + { + "id": 23, + "created_at": "2025-11-14T10:28:57.166199Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-45" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:28:57.112037Z" + } + } + ] + } + }, + { + "id": 24, + "created_at": "2025-11-14T10:29:17.183369Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-46" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:29:17.111395Z" + } + } + ] + } + }, + { + "id": 25, + "created_at": "2025-11-14T10:29:37.530234Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-47" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:29:37.107648Z" + } + } + ] + } + }, + { + "id": 26, + "created_at": "2025-11-14T10:29:57.153520Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-48" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:29:57.107746Z" + } + } + ] + } + }, + { + "id": 27, + "created_at": "2025-11-14T10:30:17.151411Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-49" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:30:17.108093Z" + } + } + ] + } + }, + { + "id": 28, + "created_at": "2025-11-14T10:30:37.189863Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-50" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:30:37.108377Z" + } + } + ] + } + }, + { + "id": 29, + "created_at": "2025-11-14T10:30:57.133894Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-51" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:30:57.030022Z" + } + } + ] + } + }, + { + "id": 30, + "created_at": "2025-11-14T10:31:17.165283Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-52" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:31:17.113978Z" + } + } + ] + } + }, + { + "id": 31, + "created_at": "2025-11-14T10:31:37.164775Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-53" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:31:37.113329Z" + } + } + ] + } + }, + { + "id": 32, + "created_at": "2025-11-14T10:31:57.151220Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-54" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:31:57.109168Z" + } + } + ] + } + }, + { + "id": 33, + "created_at": "2025-11-14T10:32:17.157902Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-55" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:32:17.114112Z" + } + } + ] + } + }, + { + "id": 34, + "created_at": "2025-11-14T10:32:37.150847Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-56" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:32:37.109589Z" + } + } + ] + } + }, + { + "id": 35, + "created_at": "2025-11-14T10:32:57.104392Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-57" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:32:57.065222Z" + } + } + ] + } + }, + { + "id": 36, + "created_at": "2025-11-14T10:33:17.160628Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-58" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:33:17.110021Z" + } + } + ] + } + }, + { + "id": 37, + "created_at": "2025-11-14T10:33:37.213604Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-59" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:33:37.114701Z" + } + } + ] + } + }, + { + "id": 38, + "created_at": "2025-11-14T10:33:57.166928Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-60" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:33:57.115621Z" + } + } + ] + } + }, + { + "id": 39, + "created_at": "2025-11-14T10:34:17.162550Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-61" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:34:17.110517Z" + } + } + ] + } + }, + { + "id": 40, + "created_at": "2025-11-14T10:34:37.162235Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "get_request": { + "uri": "https://example.com/test-url-62" + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T10:34:37.110845Z" + } + } + ] + } + }, + { + "id": 41, + "created_at": "2025-11-14T10:34:57.193325Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'REFUSAL_BY_BANK' - 'Internal error'", + "sub": { + "code": "rejected_by_issuer" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2025-11-14T10:34:57.119663Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'REFUSAL_BY_BANK' - 'Internal error'", + "sub": { + "code": "rejected_by_issuer" + } + } + } + } + }, + "occurred_at": "2025-11-14T10:34:57.119663Z" + } + } + ] + } + }, + { + "id": 42, + "created_at": "2025-11-14T10:34:57.248628Z", + "source": { + "invoice_id": "test-invoice-5" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'REFUSAL_BY_BANK' - 'Internal error'", + "sub": { + "code": "rejected_by_issuer" + } + } + } + } + } + } + }, + "occurred_at": "2025-11-14T10:34:57.218873Z" + } + } + ] + } + } +] diff --git a/src/test/resources/payments/response (6).txt b/src/test/resources/payments/response (6).txt new file mode 100644 index 0000000..fa5f5e3 --- /dev/null +++ b/src/test/resources/payments/response (6).txt @@ -0,0 +1,759 @@ +[ + { + "id": 1, + "created_at": "2025-11-14T12:25:53.051366Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_created": { + "invoice": { + "id": "2Bd6RPmJHVo", + "owner_id": "test-party-2", + "party_revision": 112, + "shop_id": "test-shop-3", + "created_at": "2025-11-14T12:25:53.036636Z", + "status": { + "unpaid": [] + }, + "details": { + "product": "126460576" + }, + "due": "2025-11-15T12:25:52Z", + "cost": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + }, + "context": { + "type": "application/json", + "data": "{\"X-traceparent\":\"00-00000000000000000000000000000000-0000000000000003-01\",\"transaction_id\":\"test-transaction-embedded\",\"user_id\":\"test-user-embedded\"}" + }, + "external_id": "test-external-5" + } + } + } + ] + } + }, + { + "id": 2, + "created_at": "2025-11-14T12:25:53.391900Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_started": { + "payment": { + "id": "1", + "created_at": "2025-11-14T12:25:53.385522Z", + "status": { + "pending": [] + }, + "context": { + "type": "application/json", + "data": "{\"transaction_id\":\"test-transaction-embedded\",\"user_id\":\"test-user-embedded\"}" + }, + "cost": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + }, + "domain_revision": 72892, + "flow": { + "instant": [] + }, + "payer": { + "payment_resource": { + "resource": { + "payment_tool": { + "bank_card": { + "token": "test-token", + "payment_system": { + "id": "VISA" + }, + "bin": "411111", + "last_digits": "1111", + "issuer_country": "KAZ", + "bank_name": "TEST BANK", + "metadata": { + "com.rbkmoney.binbase": { + "obj": [ + { + "key": { + "str": "version" + }, + "value": { + "i": 1 + } + } + ] + } + }, + "is_cvv_empty": false, + "exp_date": { + "month": 2, + "year": 2026 + }, + "category": "REWARDS" + } + }, + "payment_session_id": "test-payment-session-6", + "client_info": { + "ip_address": "198.51.100.40", + "fingerprint": "test-fingerprint-6", + "peer_ip_address": "198.51.100.41", + "user_ip_address": "198.51.100.42" + } + }, + "contact_info": { + "phone_number": "+70000000007", + "email": "user12@example.test", + "country": "KAZ", + "state": "Kazakhstan" + } + } + }, + "payer_session_info": { + "redirect_url": "https://example.com/test-url-63" + }, + "party_revision": 112, + "owner_id": "test-party-2", + "shop_id": "test-shop-3", + "make_recurrent": true, + "external_id": "test-external-6", + "processing_deadline": "2025-11-14T12:27:53.363Z", + "registration_origin": { + "merchant": [] + } + } + } + }, + "occurred_at": "2025-11-14T12:25:53.385522Z" + } + } + ] + } + }, + { + "id": 3, + "created_at": "2025-11-14T12:25:53.405481Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_initiated": [] + }, + "occurred_at": "2025-11-14T12:25:53.404713Z" + } + } + ] + } + }, + { + "id": 4, + "created_at": "2025-11-14T12:25:53.417827Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_shop_limit_applied": [] + }, + "occurred_at": "2025-11-14T12:25:53.417122Z" + } + } + ] + } + }, + { + "id": 5, + "created_at": "2025-11-14T12:25:53.493080Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_risk_score_changed": { + "risk_score": "high" + } + }, + "occurred_at": "2025-11-14T12:25:53.429646Z" + } + } + ] + } + }, + { + "id": 6, + "created_at": "2025-11-14T12:25:53.600184Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_route_changed": { + "route": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "candidates": [ + { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2171 + } + }, + { + "provider": { + "id": 275 + }, + "terminal": { + "id": 2304 + } + } + ], + "scores": [ + { + "key": { + "provider": { + "id": 275 + }, + "terminal": { + "id": 2304 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 3000, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2171 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 2000, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 1.00000000000000000000e+00, + "blacklist_condition": 0 + } + }, + { + "key": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "value": { + "availability_condition": 1, + "conversion_condition": 1, + "terminal_priority_rating": 4000, + "route_pin": 0, + "random_condition": 1, + "availability": 1.00000000000000000000e+00, + "conversion": 9.95015913048699962573e-01, + "blacklist_condition": 0 + } + } + ], + "limits": [ + { + "key": { + "provider": { + "id": 275 + }, + "terminal": { + "id": 2304 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 254 + }, + "terminal": { + "id": 2171 + } + }, + "value": [] + }, + { + "key": { + "provider": { + "id": 170 + }, + "terminal": { + "id": 2529 + } + }, + "value": [] + } + ] + } + }, + "occurred_at": "2025-11-14T12:25:53.506444Z" + } + } + ] + } + }, + { + "id": 7, + "created_at": "2025-11-14T12:25:53.660826Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_cash_flow_changed": { + "cash_flow": [ + { + "source": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 82657505, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-4", + "shop_id": "test-shop-3" + } + } + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "volume": { + "amount": 9000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account_id": 329, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 170 + }, + "terminal_ref": { + "id": 2529 + } + } + } + } + }, + "destination": { + "account_type": { + "merchant": "settlement" + }, + "account_id": 82657505, + "transaction_account": { + "merchant": { + "type": "settlement", + "owner": { + "party_id": "test-uuid-4", + "shop_id": "test-shop-3" + } + } + } + }, + "volume": { + "amount": 200000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account_id": 315, + "transaction_account": { + "system": { + "type": "settlement" + } + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account_id": 329, + "transaction_account": { + "provider": { + "type": "settlement", + "owner": { + "provider_ref": { + "id": 170 + }, + "terminal_ref": { + "id": 2529 + } + } + } + } + }, + "volume": { + "amount": 8400, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + }, + "occurred_at": "2025-11-14T12:25:53.613294Z" + } + } + ] + } + }, + { + "id": 8, + "created_at": "2025-11-14T12:25:53.675597Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_started": [] + } + } + }, + "occurred_at": "2025-11-14T12:25:53.674770Z" + } + } + ] + } + }, + { + "id": 9, + "created_at": "2025-11-14T12:25:53.846349Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_transaction_bound": { + "trx": { + "id": "GE00000000000004", + "extra": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T12:25:53.687454Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"trxId\":\"test-provider-trx-alt-1\",\"step\":\"CHECK_STATUS\",\"options\":{},\"start_date_time_polling\":1763123153.837146306,\"max_date_time_polling\":1763123753.837145822}" + } + } + } + }, + "occurred_at": "2025-11-14T12:25:53.687454Z" + } + } + ] + } + }, + { + "id": 10, + "created_at": "2025-11-14T12:26:00.019124Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_proxy_state_changed": { + "proxy_state": "{\"md\":\"test-uuid-9\",\"paReq\":\"eyJpZnJhbWVzIjpbeyJ1cmwiOiJodHRwczovLzNkczIucmFkYXJwYXltZW50Lm9ubGluZS8zZHNzZXJ2ZXIvYXBpL3YxL2NsaWVudC9nYXRoZXI/dGhyZWVEU1NlcnZlclRyYW5zSUQ9ZDkyY2ZiYTQtZDQxNi00NDI0LTgxOTEtZDNiZjU0ZWQ1YzRjIiwibWV0aG9kIjoiUE9TVCIsImZvcm1fZGF0YSI6eyJ0aHJlZURTTWV0aG9kRGF0YSI6IiJ9LCJkZWxheSI6IjEwMDAifV0sImNoYWxsZW5nZV9kYXRhIjp7ImNyZXEiOiIiLCJhY3NfdXJsIjoiIiwic2tpcF90ZHMiOmZhbHNlLCJpc18zZHMxIjp0cnVlfX0=\",\"acsUrl\":\"https://example.com/test-url-64\",\"threeDsMethodData\":\"POST\",\"trxId\":\"test-provider-trx-alt-1\",\"step\":\"FINISH_THREE_DS\",\"options\":{},\"start_date_time_polling\":1763123153.837146306,\"max_date_time_polling\":1763123753.837145822}" + } + } + } + }, + "occurred_at": "2025-11-14T12:25:59.926523Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_interaction_changed": { + "interaction": { + "redirect": { + "post_request": { + "uri": "https://example.com/test-url-65", + "form": { + "TermUrl": "https://example.com/test-url-66", + "PaReq": "eyJpZnJhbWVzIjpbeyJ1cmwiOiJodHRwczovLzNkczIucmFkYXJwYXltZW50Lm9ubGluZS8zZHNzZXJ2ZXIvYXBpL3YxL2NsaWVudC9nYXRoZXI/dGhyZWVEU1NlcnZlclRyYW5zSUQ9ZDkyY2ZiYTQtZDQxNi00NDI0LTgxOTEtZDNiZjU0ZWQ1YzRjIiwibWV0aG9kIjoiUE9TVCIsImZvcm1fZGF0YSI6eyJ0aHJlZURTTWV0aG9kRGF0YSI6IiJ9LCJkZWxheSI6IjEwMDAifV0sImNoYWxsZW5nZV9kYXRhIjp7ImNyZXEiOiIiLCJhY3NfdXJsIjoiIiwic2tpcF90ZHMiOmZhbHNlLCJpc18zZHMxIjp0cnVlfX0=", + "MD": "test-uuid-9" + } + } + } + }, + "status": { + "requested": [] + } + } + } + } + }, + "occurred_at": "2025-11-14T12:25:59.926523Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_suspended": { + "tag": "test-uuid-9", + "timeout_behaviour": { + "operation_failure": { + "failure": { + "code": "authorization_failed", + "reason": "'3DS_NOT_FINISHED' - 'null'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + }, + "occurred_at": "2025-11-14T12:25:59.926523Z" + } + } + ] + } + }, + { + "id": 11, + "created_at": "2025-11-14T12:26:20.939437Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_session_change": { + "target": { + "processed": [] + }, + "payload": { + "session_finished": { + "result": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'3DS_NOT_FINISHED' - 'null'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + } + }, + "occurred_at": "2025-11-14T12:26:20.931558Z" + } + }, + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_rollback_started": { + "reason": { + "failure": { + "code": "authorization_failed", + "reason": "'3DS_NOT_FINISHED' - 'null'", + "sub": { + "code": "unknown" + } + } + } + } + }, + "occurred_at": "2025-11-14T12:26:20.931558Z" + } + } + ] + } + }, + { + "id": 12, + "created_at": "2025-11-14T12:26:20.991918Z", + "source": { + "invoice_id": "test-invoice-6" + }, + "payload": { + "invoice_changes": [ + { + "invoice_payment_change": { + "id": "1", + "payload": { + "invoice_payment_status_changed": { + "status": { + "failed": { + "failure": { + "failure": { + "code": "authorization_failed", + "reason": "'3DS_NOT_FINISHED' - 'null'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + }, + "occurred_at": "2025-11-14T12:26:20.960200Z" + } + } + ] + } + } +] diff --git a/src/test/resources/withdrawals/211890_events.txt b/src/test/resources/withdrawals/211890_events.txt new file mode 100644 index 0000000..6b35492 --- /dev/null +++ b/src/test/resources/withdrawals/211890_events.txt @@ -0,0 +1,1058 @@ +[ + { + "event_id": 1, + "occured_at": "2026-02-17T18:15:27.166262Z", + "change": { + "created": { + "withdrawal": { + "id": "211890", + "body": { + "amount": 2900000, + "currency": { + "symbolic_code": "RUB" + } + }, + "wallet_id": "3313", + "destination_id": "212279", + "party_id": "test-party-w-1", + "created_at": "2026-02-17T18:15:27.083Z", + "domain_revision": 2348, + "metadata": { + "user_id": { + "str": "test-user-meta" + }, + "transaction_id": { + "str": "test-withdrawal-transaction-1" + }, + "X-traceparent": { + "str": "00-00000000000000000000000000000000-0000000000000000-01" + } + }, + "external_id": "test-withdrawal-external-1" + } + } + } + }, + { + "event_id": 2, + "occured_at": "2026-02-17T18:15:27.166262Z", + "change": { + "status_changed": { + "status": { + "pending": [] + } + } + } + }, + { + "event_id": 3, + "occured_at": "2026-02-17T18:15:27.166262Z", + "change": { + "resource": { + "got": { + "resource": { + "bank_card": { + "bank_card": { + "token": "test-token", + "bin": "411111", + "masked_pan": "1111", + "issuer_country": "RUS", + "bank_name": "TEST BANK", + "card_type": "debit", + "bin_data_id": { + "i": 13938 + } + } + } + } + } + } + } + }, + { + "event_id": 4, + "occured_at": "2026-02-17T18:15:27.360478Z", + "change": { + "route": { + "route": { + "provider_id": 393, + "terminal_id": 2226, + "provider_id_legacy": "93" + } + } + } + }, + { + "event_id": 5, + "occured_at": "2026-02-17T18:15:27.450848Z", + "change": { + "transfer": { + "payload": { + "created": { + "transfer": { + "id": "ff/withdrawal/211890/1", + "cashflow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 212626 + } + }, + "volume": { + "amount": 2900000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "volume": { + "amount": 145000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 12228 + } + }, + "volume": { + "amount": 130500, + "currency": { + "symbolic_code": "RUB" + } + } + } + ] + } + } + } + } + } + } + }, + { + "event_id": 6, + "occured_at": "2026-02-17T18:15:27.450848Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "created": [] + } + } + } + } + } + }, + { + "event_id": 7, + "occured_at": "2026-02-17T18:15:27.541755Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "prepared": [] + } + } + } + } + } + }, + { + "event_id": 8, + "occured_at": "2026-02-17T18:15:27.578582Z", + "change": { + "limit_check": { + "details": { + "wallet_sender": { + "ok": [] + } + } + } + } + }, + { + "event_id": 9, + "occured_at": "2026-02-17T18:15:27.626527Z", + "change": { + "session": { + "id": "211890/1", + "payload": { + "started": [] + } + } + } + }, + { + "event_id": 10, + "occured_at": "2026-02-17T18:15:59.249252Z", + "change": { + "session": { + "id": "211890/1", + "payload": { + "finished": { + "result": { + "failed": { + "failure": { + "code": "authorization_failed", + "reason": "'330' - 'Destination account unavailable'", + "sub": { + "code": "unknown" + } + } + } + } + } + } + } + } + }, + { + "event_id": 11, + "occured_at": "2026-02-17T18:15:59.318438Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "cancelled": [] + } + } + } + } + } + }, + { + "event_id": 12, + "occured_at": "2026-02-17T18:15:59.431803Z", + "change": { + "route": { + "route": { + "provider_id": 518, + "terminal_id": 2465, + "provider_id_legacy": "218" + } + } + } + }, + { + "event_id": 13, + "occured_at": "2026-02-17T18:15:59.512814Z", + "change": { + "transfer": { + "payload": { + "created": { + "transfer": { + "id": "ff/withdrawal/211890/2", + "cashflow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 212626 + } + }, + "volume": { + "amount": 2900000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "volume": { + "amount": 145000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 10683 + } + }, + "volume": { + "amount": 116000, + "currency": { + "symbolic_code": "RUB" + } + } + } + ] + } + } + } + } + } + } + }, + { + "event_id": 14, + "occured_at": "2026-02-17T18:15:59.512814Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "created": [] + } + } + } + } + } + }, + { + "event_id": 15, + "occured_at": "2026-02-17T18:15:59.578280Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "prepared": [] + } + } + } + } + } + }, + { + "event_id": 16, + "occured_at": "2026-02-17T18:15:59.619442Z", + "change": { + "limit_check": { + "details": { + "wallet_sender": { + "ok": [] + } + } + } + } + }, + { + "event_id": 17, + "occured_at": "2026-02-17T18:15:59.670130Z", + "change": { + "session": { + "id": "211890/2", + "payload": { + "started": [] + } + } + } + }, + { + "event_id": 18, + "occured_at": "2026-02-17T18:33:00.195739Z", + "change": { + "session": { + "id": "211890/2", + "payload": { + "finished": { + "result": { + "succeeded": [] + } + } + } + } + } + }, + { + "event_id": 19, + "occured_at": "2026-02-17T18:33:00.265856Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "committed": [] + } + } + } + } + } + }, + { + "event_id": 20, + "occured_at": "2026-02-17T18:33:00.283712Z", + "change": { + "status_changed": { + "status": { + "succeeded": [] + } + } + } + }, + { + "event_id": 21, + "occured_at": "2026-02-20T11:51:00.389Z", + "change": { + "adjustment": { + "id": "test-withdrawal-adjustment-1", + "payload": { + "created": { + "adjustment": { + "id": "test-withdrawal-adjustment-1", + "status": { + "pending": [] + }, + "changes_plan": { + "new_cash_flow": { + "old_cash_flow_inverted": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 212626 + } + }, + "destination": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "volume": { + "amount": 2900000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "destination": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "volume": { + "amount": 145000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 10683 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "volume": { + "amount": 116000, + "currency": { + "symbolic_code": "RUB" + } + } + } + ] + }, + "new_cash_flow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 212626 + } + }, + "volume": { + "amount": 2900000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "volume": { + "amount": 145000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 10683 + } + }, + "volume": { + "amount": 116000, + "currency": { + "symbolic_code": "RUB" + } + } + } + ] + } + }, + "new_domain_revision": { + "new_domain_revision": 2433 + } + }, + "created_at": "2026-02-20T11:51:00.388Z", + "domain_revision": 2433, + "operation_timestamp": "2026-02-17T18:15:27.083Z" + } + } + } + } + } + }, + { + "event_id": 22, + "occured_at": "2026-02-20T11:51:00.419078Z", + "change": { + "adjustment": { + "id": "test-withdrawal-adjustment-1", + "payload": { + "transfer": { + "payload": { + "created": { + "transfer": { + "id": "ff/adjustment/test-withdrawal-adjustment-1", + "cashflow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 212626 + } + }, + "destination": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "volume": { + "amount": 2900000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "destination": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "volume": { + "amount": 145000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 10683 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "volume": { + "amount": 116000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 212626 + } + }, + "volume": { + "amount": 2900000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-party-w-1", + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5331 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "volume": { + "amount": 145000, + "currency": { + "symbolic_code": "RUB" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 5012 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "RUB" + }, + "account_id": 10683 + } + }, + "volume": { + "amount": 116000, + "currency": { + "symbolic_code": "RUB" + } + } + } + ] + } + } + } + } + } + } + } + } + }, + { + "event_id": 23, + "occured_at": "2026-02-20T11:51:00.419078Z", + "change": { + "adjustment": { + "id": "test-withdrawal-adjustment-1", + "payload": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "created": [] + } + } + } + } + } + } + } + }, + { + "event_id": 24, + "occured_at": "2026-02-20T11:51:00.448121Z", + "change": { + "adjustment": { + "id": "test-withdrawal-adjustment-1", + "payload": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "prepared": [] + } + } + } + } + } + } + } + }, + { + "event_id": 25, + "occured_at": "2026-02-20T11:51:00.473244Z", + "change": { + "adjustment": { + "id": "test-withdrawal-adjustment-1", + "payload": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "committed": [] + } + } + } + } + } + } + } + }, + { + "event_id": 26, + "occured_at": "2026-02-20T11:51:00.493094Z", + "change": { + "adjustment": { + "id": "test-withdrawal-adjustment-1", + "payload": { + "status_changed": { + "status": { + "succeeded": [] + } + } + } + } + } + } +] diff --git a/src/test/resources/withdrawals/257060.txt b/src/test/resources/withdrawals/257060.txt new file mode 100644 index 0000000..860cd38 --- /dev/null +++ b/src/test/resources/withdrawals/257060.txt @@ -0,0 +1,300 @@ +[ + { + "event_id": 1, + "occured_at": "2026-03-13T14:32:23.806242Z", + "change": { + "created": { + "withdrawal": { + "id": "257060", + "body": { + "amount": 5000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "wallet_id": "3424", + "destination_id": "257493", + "party_id": "test-uuid-1", + "created_at": "2026-03-13T14:32:23.684Z", + "domain_revision": 3032, + "metadata": { + "user_id": { + "str": "test-user-meta" + }, + "transaction_id": { + "str": "test-uuid-12" + }, + "X-traceparent": { + "str": "00-6e3079944dfec65673bfda24e2950e5e-5a49652edfd0007d-01" + } + }, + "external_id": "test-external-11" + } + } + } + }, + { + "event_id": 2, + "occured_at": "2026-03-13T14:32:23.806242Z", + "change": { + "status_changed": { + "status": { + "pending": [] + } + } + } + }, + { + "event_id": 3, + "occured_at": "2026-03-13T14:32:23.806242Z", + "change": { + "resource": { + "got": { + "resource": { + "bank_card": { + "bank_card": { + "token": "test-token", + "bin": "411111", + "masked_pan": "4140", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "card_type": "debit", + "bin_data_id": { + "i": 1001 + } + } + } + } + } + } + } + }, + { + "event_id": 4, + "occured_at": "2026-03-13T14:32:23.974694Z", + "change": { + "route": { + "route": { + "provider_id": 10, + "terminal_id": 17, + "provider_id_legacy": "-290" + } + } + } + }, + { + "event_id": 5, + "occured_at": "2026-03-13T14:32:24.059818Z", + "change": { + "transfer": { + "payload": { + "created": { + "transfer": { + "id": "ff/withdrawal/257060/1", + "cashflow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5344 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 257940 + } + }, + "volume": { + "amount": 5000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 137944 + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5344 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "volume": { + "amount": 500000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + } + } + } + } + } + }, + { + "event_id": 6, + "occured_at": "2026-03-13T14:32:24.059818Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "created": [] + } + } + } + } + } + }, + { + "event_id": 7, + "occured_at": "2026-03-13T14:32:24.124238Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "prepared": [] + } + } + } + } + } + }, + { + "event_id": 8, + "occured_at": "2026-03-13T14:32:24.177005Z", + "change": { + "limit_check": { + "details": { + "wallet_sender": { + "ok": [] + } + } + } + } + }, + { + "event_id": 9, + "occured_at": "2026-03-13T14:32:24.254882Z", + "change": { + "session": { + "id": "257060/1", + "payload": { + "started": [] + } + } + } + }, + { + "event_id": 10, + "occured_at": "2026-03-13T15:19:26.767089Z", + "change": { + "session": { + "id": "257060/1", + "payload": { + "finished": { + "result": { + "succeeded": [] + } + } + } + } + } + }, + { + "event_id": 11, + "occured_at": "2026-03-13T15:19:26.885547Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "committed": [] + } + } + } + } + } + }, + { + "event_id": 12, + "occured_at": "2026-03-13T15:19:26.911820Z", + "change": { + "status_changed": { + "status": { + "succeeded": [] + } + } + } + } +] diff --git a/src/test/resources/withdrawals/257072.txt b/src/test/resources/withdrawals/257072.txt new file mode 100644 index 0000000..aba1666 --- /dev/null +++ b/src/test/resources/withdrawals/257072.txt @@ -0,0 +1,300 @@ +[ + { + "event_id": 1, + "occured_at": "2026-03-13T14:39:58.581564Z", + "change": { + "created": { + "withdrawal": { + "id": "257072", + "body": { + "amount": 5000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "wallet_id": "3425", + "destination_id": "257505", + "party_id": "test-uuid-1", + "created_at": "2026-03-13T14:39:58.465Z", + "domain_revision": 3035, + "metadata": { + "user_id": { + "str": "test-user-meta" + }, + "transaction_id": { + "str": "test-uuid-13" + }, + "X-traceparent": { + "str": "00-d5ef4b42bf2f416f86ebc6433f55d14d-60f94594335464ff-01" + } + }, + "external_id": "test-external-12" + } + } + } + }, + { + "event_id": 2, + "occured_at": "2026-03-13T14:39:58.581564Z", + "change": { + "status_changed": { + "status": { + "pending": [] + } + } + } + }, + { + "event_id": 3, + "occured_at": "2026-03-13T14:39:58.581564Z", + "change": { + "resource": { + "got": { + "resource": { + "bank_card": { + "bank_card": { + "token": "test-token", + "bin": "411111", + "masked_pan": "1857", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "card_type": "debit", + "bin_data_id": { + "i": 1001 + } + } + } + } + } + } + } + }, + { + "event_id": 4, + "occured_at": "2026-03-13T14:39:58.723396Z", + "change": { + "route": { + "route": { + "provider_id": 10, + "terminal_id": 17, + "provider_id_legacy": "-290" + } + } + } + }, + { + "event_id": 5, + "occured_at": "2026-03-13T14:39:58.805110Z", + "change": { + "transfer": { + "payload": { + "created": { + "transfer": { + "id": "ff/withdrawal/257072/1", + "cashflow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5346 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 257953 + } + }, + "volume": { + "amount": 5000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 137944 + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5346 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "volume": { + "amount": 500000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + } + } + } + } + } + }, + { + "event_id": 6, + "occured_at": "2026-03-13T14:39:58.805110Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "created": [] + } + } + } + } + } + }, + { + "event_id": 7, + "occured_at": "2026-03-13T14:39:58.872727Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "prepared": [] + } + } + } + } + } + }, + { + "event_id": 8, + "occured_at": "2026-03-13T14:39:58.926585Z", + "change": { + "limit_check": { + "details": { + "wallet_sender": { + "ok": [] + } + } + } + } + }, + { + "event_id": 9, + "occured_at": "2026-03-13T14:39:58.999513Z", + "change": { + "session": { + "id": "257072/1", + "payload": { + "started": [] + } + } + } + }, + { + "event_id": 10, + "occured_at": "2026-03-13T15:07:00.058937Z", + "change": { + "session": { + "id": "257072/1", + "payload": { + "finished": { + "result": { + "succeeded": [] + } + } + } + } + } + }, + { + "event_id": 11, + "occured_at": "2026-03-13T15:07:00.176718Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "committed": [] + } + } + } + } + } + }, + { + "event_id": 12, + "occured_at": "2026-03-13T15:07:00.204011Z", + "change": { + "status_changed": { + "status": { + "succeeded": [] + } + } + } + } +] diff --git a/src/test/resources/withdrawals/257077.txt b/src/test/resources/withdrawals/257077.txt new file mode 100644 index 0000000..ebeea3a --- /dev/null +++ b/src/test/resources/withdrawals/257077.txt @@ -0,0 +1,300 @@ +[ + { + "event_id": 1, + "occured_at": "2026-03-13T14:44:15.286455Z", + "change": { + "created": { + "withdrawal": { + "id": "257077", + "body": { + "amount": 8000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "wallet_id": "3425", + "destination_id": "257510", + "party_id": "test-uuid-1", + "created_at": "2026-03-13T14:44:15.180Z", + "domain_revision": 3041, + "metadata": { + "user_id": { + "str": "test-user-meta" + }, + "transaction_id": { + "str": "test-uuid-14" + }, + "X-traceparent": { + "str": "00-5284872ed1de94b5b01484e8a93c7e4a-7d737a2d6a6dbed7-01" + } + }, + "external_id": "test-external-13" + } + } + } + }, + { + "event_id": 2, + "occured_at": "2026-03-13T14:44:15.286455Z", + "change": { + "status_changed": { + "status": { + "pending": [] + } + } + } + }, + { + "event_id": 3, + "occured_at": "2026-03-13T14:44:15.286455Z", + "change": { + "resource": { + "got": { + "resource": { + "bank_card": { + "bank_card": { + "token": "test-token", + "bin": "411111", + "masked_pan": "4733", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "card_type": "debit", + "bin_data_id": { + "i": 1001 + } + } + } + } + } + } + } + }, + { + "event_id": 4, + "occured_at": "2026-03-13T14:44:15.417282Z", + "change": { + "route": { + "route": { + "provider_id": 10, + "terminal_id": 17, + "provider_id_legacy": "-290" + } + } + } + }, + { + "event_id": 5, + "occured_at": "2026-03-13T14:44:15.479707Z", + "change": { + "transfer": { + "payload": { + "created": { + "transfer": { + "id": "ff/withdrawal/257077/1", + "cashflow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5346 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 257958 + } + }, + "volume": { + "amount": 8000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 137944 + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5346 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "volume": { + "amount": 800000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + } + } + } + } + } + }, + { + "event_id": 6, + "occured_at": "2026-03-13T14:44:15.479707Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "created": [] + } + } + } + } + } + }, + { + "event_id": 7, + "occured_at": "2026-03-13T14:44:15.530892Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "prepared": [] + } + } + } + } + } + }, + { + "event_id": 8, + "occured_at": "2026-03-13T14:44:15.568387Z", + "change": { + "limit_check": { + "details": { + "wallet_sender": { + "ok": [] + } + } + } + } + }, + { + "event_id": 9, + "occured_at": "2026-03-13T14:44:15.614807Z", + "change": { + "session": { + "id": "257077/1", + "payload": { + "started": [] + } + } + } + }, + { + "event_id": 10, + "occured_at": "2026-03-13T15:11:17.329684Z", + "change": { + "session": { + "id": "257077/1", + "payload": { + "finished": { + "result": { + "succeeded": [] + } + } + } + } + } + }, + { + "event_id": 11, + "occured_at": "2026-03-13T15:11:17.518880Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "committed": [] + } + } + } + } + } + }, + { + "event_id": 12, + "occured_at": "2026-03-13T15:11:17.535031Z", + "change": { + "status_changed": { + "status": { + "succeeded": [] + } + } + } + } +] diff --git a/src/test/resources/withdrawals/257080.txt b/src/test/resources/withdrawals/257080.txt new file mode 100644 index 0000000..332aea4 --- /dev/null +++ b/src/test/resources/withdrawals/257080.txt @@ -0,0 +1,300 @@ +[ + { + "event_id": 1, + "occured_at": "2026-03-13T14:46:09.773010Z", + "change": { + "created": { + "withdrawal": { + "id": "257080", + "body": { + "amount": 5000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "wallet_id": "3425", + "destination_id": "257513", + "party_id": "test-uuid-1", + "created_at": "2026-03-13T14:46:09.645Z", + "domain_revision": 3041, + "metadata": { + "user_id": { + "str": "test-user-meta" + }, + "transaction_id": { + "str": "test-uuid-15" + }, + "X-traceparent": { + "str": "00-18e1062252a360b297da1bde68a3fd51-6e1b0c29aa59ca7b-01" + } + }, + "external_id": "test-external-14" + } + } + } + }, + { + "event_id": 2, + "occured_at": "2026-03-13T14:46:09.773010Z", + "change": { + "status_changed": { + "status": { + "pending": [] + } + } + } + }, + { + "event_id": 3, + "occured_at": "2026-03-13T14:46:09.773010Z", + "change": { + "resource": { + "got": { + "resource": { + "bank_card": { + "bank_card": { + "token": "test-token", + "bin": "411111", + "masked_pan": "9335", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "card_type": "debit", + "bin_data_id": { + "i": 1001 + } + } + } + } + } + } + } + }, + { + "event_id": 4, + "occured_at": "2026-03-13T14:46:09.914269Z", + "change": { + "route": { + "route": { + "provider_id": 10, + "terminal_id": 17, + "provider_id_legacy": "-290" + } + } + } + }, + { + "event_id": 5, + "occured_at": "2026-03-13T14:46:10.014828Z", + "change": { + "transfer": { + "payload": { + "created": { + "transfer": { + "id": "ff/withdrawal/257080/1", + "cashflow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5346 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 257961 + } + }, + "volume": { + "amount": 5000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 137944 + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5346 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "volume": { + "amount": 500000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + } + } + } + } + } + }, + { + "event_id": 6, + "occured_at": "2026-03-13T14:46:10.014828Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "created": [] + } + } + } + } + } + }, + { + "event_id": 7, + "occured_at": "2026-03-13T14:46:10.082140Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "prepared": [] + } + } + } + } + } + }, + { + "event_id": 8, + "occured_at": "2026-03-13T14:46:10.135382Z", + "change": { + "limit_check": { + "details": { + "wallet_sender": { + "ok": [] + } + } + } + } + }, + { + "event_id": 9, + "occured_at": "2026-03-13T14:46:10.222720Z", + "change": { + "session": { + "id": "257080/1", + "payload": { + "started": [] + } + } + } + }, + { + "event_id": 10, + "occured_at": "2026-03-13T15:23:11.329197Z", + "change": { + "session": { + "id": "257080/1", + "payload": { + "finished": { + "result": { + "succeeded": [] + } + } + } + } + } + }, + { + "event_id": 11, + "occured_at": "2026-03-13T15:23:11.446664Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "committed": [] + } + } + } + } + } + }, + { + "event_id": 12, + "occured_at": "2026-03-13T15:23:11.473273Z", + "change": { + "status_changed": { + "status": { + "succeeded": [] + } + } + } + } +] diff --git a/src/test/resources/withdrawals/257085.txt b/src/test/resources/withdrawals/257085.txt new file mode 100644 index 0000000..c7756f3 --- /dev/null +++ b/src/test/resources/withdrawals/257085.txt @@ -0,0 +1,300 @@ +[ + { + "event_id": 1, + "occured_at": "2026-03-13T14:48:47.914666Z", + "change": { + "created": { + "withdrawal": { + "id": "257085", + "body": { + "amount": 5000000, + "currency": { + "symbolic_code": "KZT" + } + }, + "wallet_id": "3424", + "destination_id": "257518", + "party_id": "test-uuid-1", + "created_at": "2026-03-13T14:48:47.844Z", + "domain_revision": 3044, + "metadata": { + "user_id": { + "str": "test-user-meta" + }, + "transaction_id": { + "str": "test-uuid-16" + }, + "X-traceparent": { + "str": "00-964a68a57fd12f6f165ae5106b75b7a3-e86aaf48fb3b92b2-01" + } + }, + "external_id": "test-external-15" + } + } + } + }, + { + "event_id": 2, + "occured_at": "2026-03-13T14:48:47.914666Z", + "change": { + "status_changed": { + "status": { + "pending": [] + } + } + } + }, + { + "event_id": 3, + "occured_at": "2026-03-13T14:48:47.914666Z", + "change": { + "resource": { + "got": { + "resource": { + "bank_card": { + "bank_card": { + "token": "test-token", + "bin": "411111", + "masked_pan": "4140", + "issuer_country": "kaz", + "bank_name": "TEST BANK", + "card_type": "debit", + "bin_data_id": { + "i": 1001 + } + } + } + } + } + } + } + }, + { + "event_id": 4, + "occured_at": "2026-03-13T14:48:48.044859Z", + "change": { + "route": { + "route": { + "provider_id": 10, + "terminal_id": 17, + "provider_id_legacy": "-290" + } + } + } + }, + { + "event_id": 5, + "occured_at": "2026-03-13T14:48:48.096473Z", + "change": { + "transfer": { + "payload": { + "created": { + "transfer": { + "id": "ff/withdrawal/257085/1", + "cashflow": { + "postings": [ + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5344 + } + }, + "destination": { + "account_type": { + "wallet": "receiver_destination" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 257966 + } + }, + "volume": { + "amount": 5000000, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "destination": { + "account_type": { + "provider": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 137944 + } + }, + "volume": { + "amount": 0, + "currency": { + "symbolic_code": "KZT" + } + } + }, + { + "source": { + "account_type": { + "wallet": "sender_settlement" + }, + "account": { + "party_id": "test-uuid-1", + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5344 + } + }, + "destination": { + "account_type": { + "system": "settlement" + }, + "account": { + "realm": "live", + "currency": { + "symbolic_code": "KZT" + }, + "account_id": 5011 + } + }, + "volume": { + "amount": 500000, + "currency": { + "symbolic_code": "KZT" + } + } + } + ] + } + } + } + } + } + } + }, + { + "event_id": 6, + "occured_at": "2026-03-13T14:48:48.096473Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "created": [] + } + } + } + } + } + }, + { + "event_id": 7, + "occured_at": "2026-03-13T14:48:48.147159Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "prepared": [] + } + } + } + } + } + }, + { + "event_id": 8, + "occured_at": "2026-03-13T14:48:48.183436Z", + "change": { + "limit_check": { + "details": { + "wallet_sender": { + "ok": [] + } + } + } + } + }, + { + "event_id": 9, + "occured_at": "2026-03-13T14:48:48.228673Z", + "change": { + "session": { + "id": "257085/1", + "payload": { + "started": [] + } + } + } + }, + { + "event_id": 10, + "occured_at": "2026-03-13T15:05:49.158893Z", + "change": { + "session": { + "id": "257085/1", + "payload": { + "finished": { + "result": { + "succeeded": [] + } + } + } + } + } + }, + { + "event_id": 11, + "occured_at": "2026-03-13T15:05:49.238574Z", + "change": { + "transfer": { + "payload": { + "status_changed": { + "status": { + "committed": [] + } + } + } + } + } + }, + { + "event_id": 12, + "occured_at": "2026-03-13T15:05:49.254360Z", + "change": { + "status_changed": { + "status": { + "succeeded": [] + } + } + } + } +]