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..9b114b8 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/retry/PaymentRetryService.kt @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.retry + +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/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..431110a --- /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 = + PaymentRetryServiceImpl( + 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 + } + } +}