Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class PaymentOrchestrator(
}
}

fun resume(payment: Payment): Payment = route(payment.id, payment)

private fun route(
id: PaymentId,
payment: Payment,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaymentEntity, UUID> {
fun findByProviderReference(providerReference: String): PaymentEntity?

fun findByStatusAndCreatedAtBefore(
status: PaymentStatus,
createdAt: Instant,
): List<PaymentEntity>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
@@ -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<PaymentRepository>()
private val orchestrator = mockk<PaymentOrchestrator>(relaxed = true)
private val paymentService = mockk<PaymentService>(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,
)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading