From a25026fd7413734d70835ccfa4fa9fd00d17898a Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Thu, 18 Jun 2026 12:07:05 -0300 Subject: [PATCH 1/2] feat(payments): add scheduled retry for stuck payments Add an opt-in scheduled job that re-routes payments stuck in screening (left there by a transient bank failure) back to the bank within a bounded age window, and fails them once past the deadline. The job is not transactional: it resumes a stuck payment through the orchestrator, which re-submits to the bank outside any transaction and settles the outcome in its own short transaction; each payment is attempted independently so one failure does not abort the batch. The retry window is bounded by stuck-after and max-age (validated so stuck-after never exceeds max-age), and the scheduler is off by default. A partial index on the live statuses backs the stuck-payment query. Closes #215 --- .../application/PaymentOrchestrator.kt | 2 + .../retry/PaymentRetryProperties.kt | 28 +++++++ .../application/retry/PaymentRetryService.kt | 70 ++++++++++++++++ .../payments/config/PaymentRetryScheduler.kt | 22 +++++ .../fincore/payments/config/PaymentsConfig.kt | 7 +- .../persistence/PaymentRepository.kt | 7 ++ .../db/changelog/db.changelog-master.yaml | 3 + .../v0.1/016-payments-status-index.sql | 8 ++ .../retry/PaymentRetryServiceTest.kt | 80 +++++++++++++++++++ .../config/PaymentRetrySchedulerTest.kt | 21 +++++ 10 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryProperties.kt create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/config/PaymentRetryScheduler.kt create mode 100644 services/payments/src/main/resources/db/changelog/v0.1/016-payments-status-index.sql create mode 100644 services/payments/src/test/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceTest.kt create mode 100644 services/payments/src/test/kotlin/com/fincore/payments/config/PaymentRetrySchedulerTest.kt diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentOrchestrator.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentOrchestrator.kt index 002b095..bf9347c 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentOrchestrator.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentOrchestrator.kt @@ -32,6 +32,8 @@ class PaymentOrchestrator( } } + fun resume(payment: Payment): Payment = route(payment.id, payment) + private fun route( id: PaymentId, payment: Payment, diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryProperties.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryProperties.kt new file mode 100644 index 0000000..4925fe4 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryProperties.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.retry + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated +import java.time.Duration + +@Validated +@ConfigurationProperties(prefix = "fincore.payments.retry") +data class PaymentRetryProperties( + val enabled: Boolean = false, + val cron: String = "0 */5 * * * *", + val stuckAfter: Duration = defaultStuckAfter, + val maxAge: Duration = defaultMaxAge, +) { + init { + require(!stuckAfter.isNegative) { "fincore.payments.retry.stuck-after must not be negative" } + require(!maxAge.isNegative) { "fincore.payments.retry.max-age must not be negative" } + require(stuckAfter <= maxAge) { "fincore.payments.retry.stuck-after must not exceed max-age" } + } + + private companion object { + val defaultStuckAfter: Duration = Duration.ofMinutes(5) + val defaultMaxAge: Duration = Duration.ofHours(1) + } +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt new file mode 100644 index 0000000..73e5262 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.retry + +import com.fincore.core.PaymentId +import com.fincore.payments.application.PaymentOrchestrator +import com.fincore.payments.application.PaymentService +import com.fincore.payments.domain.enum.PaymentStatus +import com.fincore.payments.infrastructure.persistence.PaymentPersistenceAdapter +import com.fincore.payments.infrastructure.persistence.PaymentRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant +import java.util.UUID + +/** + * Re-routes payments stuck in SCREENING (a transient bank failure left them there) within a bounded age window, + * failing them once past the deadline. NOT transactional: the bank re-submit happens inside [PaymentOrchestrator] + * outside any transaction, and each transition is its own short transaction. Assumes a single scheduler instance; + * overlapping ticks rely on an idempotent bank submit (the sandbox is deterministic by payment id). + */ +@Service +class PaymentRetryService( + private val paymentRepository: PaymentRepository, + private val adapter: PaymentPersistenceAdapter, + private val orchestrator: PaymentOrchestrator, + private val paymentService: PaymentService, + private val properties: PaymentRetryProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + + fun retryStuck() { + val now = Instant.now() + val stuck = paymentRepository.findByStatusAndCreatedAtBefore(PaymentStatus.SCREENING, now.minus(properties.stuckAfter)) + val deadline = now.minus(properties.maxAge) + for (entity in stuck) { + val expired = entity.createdAt.isBefore(deadline) + attempt(entity.id, expired) { + if (expired) { + paymentService.markFailed(PaymentId(entity.id), DEADLINE_REASON) + } else { + orchestrator.resume(adapter.toDomain(entity)) + } + } + } + } + + @Suppress("TooGenericExceptionCaught") // a single stuck payment must not abort the batch + private fun attempt( + paymentId: UUID, + expired: Boolean, + action: () -> Unit, + ) { + try { + action() + } catch (ex: Exception) { + log + .atWarn() + .addKeyValue("paymentId", paymentId) + .addKeyValue("expired", expired) + .setCause(ex) + .log("payment retry attempt failed") + } + } + + private companion object { + const val DEADLINE_REASON = "retry deadline exceeded" + } +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/config/PaymentRetryScheduler.kt b/services/payments/src/main/kotlin/com/fincore/payments/config/PaymentRetryScheduler.kt new file mode 100644 index 0000000..36198d9 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/config/PaymentRetryScheduler.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.config + +import com.fincore.payments.application.retry.PaymentRetryService +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled + +@Configuration +@EnableScheduling +@ConditionalOnProperty(prefix = "fincore.payments.retry", name = ["enabled"], havingValue = "true", matchIfMissing = false) +class PaymentRetryScheduler( + private val retryService: PaymentRetryService, +) { + @Scheduled(cron = "\${fincore.payments.retry.cron}") + fun retryStuck() { + retryService.retryStuck() + } +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/config/PaymentsConfig.kt b/services/payments/src/main/kotlin/com/fincore/payments/config/PaymentsConfig.kt index 9b732e1..b261765 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/config/PaymentsConfig.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/config/PaymentsConfig.kt @@ -3,11 +3,16 @@ package com.fincore.payments.config +import com.fincore.payments.application.retry.PaymentRetryProperties import com.fincore.payments.application.screening.PaymentScreeningProperties import com.fincore.payments.application.webhook.PaymentWebhookProperties import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Configuration @Configuration -@EnableConfigurationProperties(PaymentScreeningProperties::class, PaymentWebhookProperties::class) +@EnableConfigurationProperties( + PaymentScreeningProperties::class, + PaymentWebhookProperties::class, + PaymentRetryProperties::class, +) class PaymentsConfig diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentRepository.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentRepository.kt index a3ee946..92d469c 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentRepository.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentRepository.kt @@ -3,9 +3,16 @@ package com.fincore.payments.infrastructure.persistence +import com.fincore.payments.domain.enum.PaymentStatus import org.springframework.data.jpa.repository.JpaRepository +import java.time.Instant import java.util.UUID interface PaymentRepository : JpaRepository { fun findByProviderReference(providerReference: String): PaymentEntity? + + fun findByStatusAndCreatedAtBefore( + status: PaymentStatus, + createdAt: Instant, + ): List } diff --git a/services/payments/src/main/resources/db/changelog/db.changelog-master.yaml b/services/payments/src/main/resources/db/changelog/db.changelog-master.yaml index 1bb013a..fe2f774 100644 --- a/services/payments/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/services/payments/src/main/resources/db/changelog/db.changelog-master.yaml @@ -22,3 +22,6 @@ databaseChangeLog: - include: file: v0.1/015-payments-provider-reference.sql relativeToChangelogFile: true + - include: + file: v0.1/016-payments-status-index.sql + relativeToChangelogFile: true diff --git a/services/payments/src/main/resources/db/changelog/v0.1/016-payments-status-index.sql b/services/payments/src/main/resources/db/changelog/v0.1/016-payments-status-index.sql new file mode 100644 index 0000000..1458b70 --- /dev/null +++ b/services/payments/src/main/resources/db/changelog/v0.1/016-payments-status-index.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:payments-016-status-index dbms:postgresql +CREATE INDEX IF NOT EXISTS idx_payments_status_created_at + ON payments.payments(status, created_at) + WHERE status IN ('INITIATED', 'SCREENING', 'SUBMITTED'); diff --git a/services/payments/src/test/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceTest.kt b/services/payments/src/test/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceTest.kt new file mode 100644 index 0000000..ba6595a --- /dev/null +++ b/services/payments/src/test/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceTest.kt @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.retry + +import com.fincore.core.Currency +import com.fincore.core.Money +import com.fincore.core.PaymentId +import com.fincore.payments.application.PaymentOrchestrator +import com.fincore.payments.application.PaymentService +import com.fincore.payments.domain.Payment +import com.fincore.payments.domain.enum.PaymentStatus +import com.fincore.payments.infrastructure.persistence.PaymentEntity +import com.fincore.payments.infrastructure.persistence.PaymentPersistenceAdapter +import com.fincore.payments.infrastructure.persistence.PaymentRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.Duration +import java.time.Instant +import java.util.UUID + +class PaymentRetryServiceTest { + private val paymentRepository = mockk() + private val orchestrator = mockk(relaxed = true) + private val paymentService = mockk(relaxed = true) + private val service = + PaymentRetryService( + paymentRepository, + PaymentPersistenceAdapter(), + orchestrator, + paymentService, + PaymentRetryProperties(enabled = true, stuckAfter = Duration.ofMinutes(5), maxAge = Duration.ofHours(1)), + ) + + @Test + fun `should resume a payment stuck within the retry window`() { + every { paymentRepository.findByStatusAndCreatedAtBefore(any(), any()) } returns listOf(stuck(Duration.ofMinutes(10))) + + service.retryStuck() + + verify { orchestrator.resume(any()) } + verify(exactly = 0) { paymentService.markFailed(any(), any()) } + } + + @Test + fun `should fail a payment past the retry deadline without resuming`() { + every { paymentRepository.findByStatusAndCreatedAtBefore(any(), any()) } returns listOf(stuck(Duration.ofHours(2))) + + service.retryStuck() + + verify { paymentService.markFailed(any(), "retry deadline exceeded") } + verify(exactly = 0) { orchestrator.resume(any()) } + } + + @Test + fun `should continue the batch when resuming one payment throws`() { + every { paymentRepository.findByStatusAndCreatedAtBefore(any(), any()) } returns + listOf(stuck(Duration.ofMinutes(10)), stuck(Duration.ofMinutes(20))) + every { orchestrator.resume(any()) } throws RuntimeException("bank down") andThen + Payment(PaymentId.generate(), Money(BigDecimal("100.00"), Currency.USD), "order-1", PaymentStatus.SUBMITTED) + + service.retryStuck() + + verify(exactly = 2) { orchestrator.resume(any()) } + } + + private fun stuck(age: Duration): PaymentEntity = + PaymentEntity( + UUID.randomUUID(), + "order-1", + BigDecimal("100.00"), + "USD", + PaymentStatus.SCREENING, + Instant.now().minus(age), + 0L, + ) +} diff --git a/services/payments/src/test/kotlin/com/fincore/payments/config/PaymentRetrySchedulerTest.kt b/services/payments/src/test/kotlin/com/fincore/payments/config/PaymentRetrySchedulerTest.kt new file mode 100644 index 0000000..86089cd --- /dev/null +++ b/services/payments/src/test/kotlin/com/fincore/payments/config/PaymentRetrySchedulerTest.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.config + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.runner.ApplicationContextRunner + +class PaymentRetrySchedulerTest { + @Test + fun `should not register a retry scheduler bean when retry is disabled`() { + ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of()) + .withUserConfiguration(PaymentRetryScheduler::class.java) + .run { context -> + context.getBeanNamesForType(PaymentRetryScheduler::class.java).size shouldBe 0 + } + } +} From 6bbbc0a196952cc92ab399bd68125e61f39260ca Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Thu, 18 Jun 2026 12:15:49 -0300 Subject: [PATCH 2/2] refactor(payments): split PaymentRetryService into interface and impl Apply the service interface+impl rule (CLAUDE.md 8.3): PaymentRetryService is now an interface and the logic moves to PaymentRetryServiceImpl with @Service. No behaviour change. --- .../application/retry/PaymentRetryService.kt | 66 +---------------- .../retry/PaymentRetryServiceImpl.kt | 70 +++++++++++++++++++ .../retry/PaymentRetryServiceTest.kt | 2 +- 3 files changed, 73 insertions(+), 65 deletions(-) create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceImpl.kt diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt index 73e5262..9b114b8 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt @@ -3,68 +3,6 @@ package com.fincore.payments.application.retry -import com.fincore.core.PaymentId -import com.fincore.payments.application.PaymentOrchestrator -import com.fincore.payments.application.PaymentService -import com.fincore.payments.domain.enum.PaymentStatus -import com.fincore.payments.infrastructure.persistence.PaymentPersistenceAdapter -import com.fincore.payments.infrastructure.persistence.PaymentRepository -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service -import java.time.Instant -import java.util.UUID - -/** - * Re-routes payments stuck in SCREENING (a transient bank failure left them there) within a bounded age window, - * failing them once past the deadline. NOT transactional: the bank re-submit happens inside [PaymentOrchestrator] - * outside any transaction, and each transition is its own short transaction. Assumes a single scheduler instance; - * overlapping ticks rely on an idempotent bank submit (the sandbox is deterministic by payment id). - */ -@Service -class PaymentRetryService( - private val paymentRepository: PaymentRepository, - private val adapter: PaymentPersistenceAdapter, - private val orchestrator: PaymentOrchestrator, - private val paymentService: PaymentService, - private val properties: PaymentRetryProperties, -) { - private val log = LoggerFactory.getLogger(javaClass) - - fun retryStuck() { - val now = Instant.now() - val stuck = paymentRepository.findByStatusAndCreatedAtBefore(PaymentStatus.SCREENING, now.minus(properties.stuckAfter)) - val deadline = now.minus(properties.maxAge) - for (entity in stuck) { - val expired = entity.createdAt.isBefore(deadline) - attempt(entity.id, expired) { - if (expired) { - paymentService.markFailed(PaymentId(entity.id), DEADLINE_REASON) - } else { - orchestrator.resume(adapter.toDomain(entity)) - } - } - } - } - - @Suppress("TooGenericExceptionCaught") // a single stuck payment must not abort the batch - private fun attempt( - paymentId: UUID, - expired: Boolean, - action: () -> Unit, - ) { - try { - action() - } catch (ex: Exception) { - log - .atWarn() - .addKeyValue("paymentId", paymentId) - .addKeyValue("expired", expired) - .setCause(ex) - .log("payment retry attempt failed") - } - } - - private companion object { - const val DEADLINE_REASON = "retry deadline exceeded" - } +interface PaymentRetryService { + fun retryStuck() } diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceImpl.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceImpl.kt new file mode 100644 index 0000000..3870de3 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceImpl.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.retry + +import com.fincore.core.PaymentId +import com.fincore.payments.application.PaymentOrchestrator +import com.fincore.payments.application.PaymentService +import com.fincore.payments.domain.enum.PaymentStatus +import com.fincore.payments.infrastructure.persistence.PaymentPersistenceAdapter +import com.fincore.payments.infrastructure.persistence.PaymentRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant +import java.util.UUID + +/** + * Re-routes payments stuck in SCREENING (a transient bank failure left them there) within a bounded age window, + * failing them once past the deadline. NOT transactional: the bank re-submit happens inside [PaymentOrchestrator] + * outside any transaction, and each transition is its own short transaction. Assumes a single scheduler instance; + * overlapping ticks rely on an idempotent bank submit (the sandbox is deterministic by payment id). + */ +@Service +class PaymentRetryServiceImpl( + private val paymentRepository: PaymentRepository, + private val adapter: PaymentPersistenceAdapter, + private val orchestrator: PaymentOrchestrator, + private val paymentService: PaymentService, + private val properties: PaymentRetryProperties, +) : PaymentRetryService { + private val log = LoggerFactory.getLogger(javaClass) + + override fun retryStuck() { + val now = Instant.now() + val stuck = paymentRepository.findByStatusAndCreatedAtBefore(PaymentStatus.SCREENING, now.minus(properties.stuckAfter)) + val deadline = now.minus(properties.maxAge) + for (entity in stuck) { + val expired = entity.createdAt.isBefore(deadline) + attempt(entity.id, expired) { + if (expired) { + paymentService.markFailed(PaymentId(entity.id), DEADLINE_REASON) + } else { + orchestrator.resume(adapter.toDomain(entity)) + } + } + } + } + + @Suppress("TooGenericExceptionCaught") // a single stuck payment must not abort the batch + private fun attempt( + paymentId: UUID, + expired: Boolean, + action: () -> Unit, + ) { + try { + action() + } catch (ex: Exception) { + log + .atWarn() + .addKeyValue("paymentId", paymentId) + .addKeyValue("expired", expired) + .setCause(ex) + .log("payment retry attempt failed") + } + } + + private companion object { + const val DEADLINE_REASON = "retry deadline exceeded" + } +} diff --git a/services/payments/src/test/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceTest.kt b/services/payments/src/test/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceTest.kt index ba6595a..431110a 100644 --- a/services/payments/src/test/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceTest.kt +++ b/services/payments/src/test/kotlin/com/fincore/payments/application/retry/PaymentRetryServiceTest.kt @@ -27,7 +27,7 @@ class PaymentRetryServiceTest { private val orchestrator = mockk(relaxed = true) private val paymentService = mockk(relaxed = true) private val service = - PaymentRetryService( + PaymentRetryServiceImpl( paymentRepository, PaymentPersistenceAdapter(), orchestrator,