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
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.payments.application

import com.fincore.payments.application.event.PaymentInitiatedEvent
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener

/**
* Drives orchestration once an initiated payment's transaction has committed, off the request thread. AFTER_COMMIT
* guarantees the payment row is durably visible before [PaymentOrchestrator.process] screens and submits it (the
* external bank call must never run inside a transaction). A [com.fincore.payments.application.bank.BankProviderException]
* leaves the payment in SCREENING for the scheduled retry, so it is caught here rather than failing the async worker.
*/
@Component
class PaymentOrchestrationTrigger(
private val orchestrator: PaymentOrchestrator,
) {
private val log = LoggerFactory.getLogger(javaClass)

@Async("paymentOrchestrationExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Suppress("TooGenericExceptionCaught") // a failed orchestration must not crash the async worker; it falls to retry
fun onPaymentInitiated(event: PaymentInitiatedEvent) {
try {
orchestrator.process(event.paymentId)
} catch (ex: Exception) {
log
.atWarn()
.addKeyValue("paymentId", event.paymentId.toString())
.setCause(ex)
.log("orchestration after initiate failed; left for the scheduled retry")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.fincore.core.PaymentId
import com.fincore.events.EventEnvelope
import com.fincore.events.EventType
import com.fincore.events.PaymentEvents
import com.fincore.payments.application.event.PaymentInitiatedEvent
import com.fincore.payments.domain.Payment
import com.fincore.payments.domain.enum.PaymentStatus
import com.fincore.payments.domain.exception.PaymentNotFoundException
Expand All @@ -16,6 +17,7 @@ import com.fincore.payments.infrastructure.persistence.PaymentEventEntity
import com.fincore.payments.infrastructure.persistence.PaymentEventRepository
import com.fincore.payments.infrastructure.persistence.PaymentPersistenceAdapter
import com.fincore.payments.infrastructure.persistence.PaymentRepository
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
Expand All @@ -24,133 +26,138 @@ import java.time.Instant
import java.util.UUID

@Service
class PaymentServiceImpl(
private val idempotencyStore: PaymentIdempotencyStore,
private val paymentRepository: PaymentRepository,
private val paymentEventRepository: PaymentEventRepository,
private val adapter: PaymentPersistenceAdapter,
private val outboxPublisher: PaymentOutboxEventPublisher,
private val objectMapper: ObjectMapper,
private val metrics: PaymentMetrics,
) : PaymentService {
override fun initiate(command: InitiatePaymentCommand): Payment {
val keyHash = Sha256.hex(command.idempotencyKey)
var attempt = 0
while (true) {
try {
return idempotencyStore.reserveOrRun(keyHash) { createPayment(command) }
} catch (race: PaymentIdempotencyRaceException) {
attempt++
if (attempt >= MAX_ATTEMPTS) throw PaymentConcurrencyException(race)
class PaymentServiceImpl
@Suppress("LongParameterList") // cohesive collaborators of the payment write side; splitting them would not aid clarity
constructor(
private val idempotencyStore: PaymentIdempotencyStore,
private val paymentRepository: PaymentRepository,
private val paymentEventRepository: PaymentEventRepository,
private val adapter: PaymentPersistenceAdapter,
private val outboxPublisher: PaymentOutboxEventPublisher,
private val objectMapper: ObjectMapper,
private val metrics: PaymentMetrics,
private val eventPublisher: ApplicationEventPublisher,
) : PaymentService {
override fun initiate(command: InitiatePaymentCommand): Payment {
val keyHash = Sha256.hex(command.idempotencyKey)
var attempt = 0
while (true) {
try {
return idempotencyStore.reserveOrRun(keyHash) { createPayment(command) }
} catch (race: PaymentIdempotencyRaceException) {
attempt++
if (attempt >= MAX_ATTEMPTS) throw PaymentConcurrencyException(race)
}
}
}
}

@Transactional(readOnly = true)
override fun list(
page: Int,
size: Int,
): PaymentPage {
val pageable = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")))
val result = paymentRepository.findAll(pageable)
return PaymentPage(
items = result.content.map(adapter::toDomain),
page = page,
size = size,
totalElements = result.totalElements,
totalPages = result.totalPages,
)
}
@Transactional(readOnly = true)
override fun list(
page: Int,
size: Int,
): PaymentPage {
val pageable = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")))
val result = paymentRepository.findAll(pageable)
return PaymentPage(
items = result.content.map(adapter::toDomain),
page = page,
size = size,
totalElements = result.totalElements,
totalPages = result.totalPages,
)
}

@Transactional(readOnly = true)
override fun get(id: PaymentId): Payment =
adapter.toDomain(paymentRepository.findById(id.value).orElseThrow { PaymentNotFoundException(id) })
@Transactional(readOnly = true)
override fun get(id: PaymentId): Payment =
adapter.toDomain(paymentRepository.findById(id.value).orElseThrow { PaymentNotFoundException(id) })

@Transactional
override fun cancel(id: PaymentId): Payment = transition(id, PaymentStatus.CANCELLED, PaymentEvents.PaymentCancelled)
@Transactional
override fun cancel(id: PaymentId): Payment = transition(id, PaymentStatus.CANCELLED, PaymentEvents.PaymentCancelled)

@Transactional
override fun screen(id: PaymentId): Payment {
val entity = paymentRepository.findById(id.value).orElseThrow { PaymentNotFoundException(id) }
val payment = adapter.toDomain(entity)
payment.transitionTo(PaymentStatus.SCREENING)
entity.status = payment.status
paymentRepository.saveAndFlush(entity)
metrics.record(PaymentStatus.SCREENING)
return payment
}
@Transactional
override fun screen(id: PaymentId): Payment {
val entity = paymentRepository.findById(id.value).orElseThrow { PaymentNotFoundException(id) }
val payment = adapter.toDomain(entity)
payment.transitionTo(PaymentStatus.SCREENING)
entity.status = payment.status
paymentRepository.saveAndFlush(entity)
metrics.record(PaymentStatus.SCREENING)
return payment
}

@Transactional
override fun markSubmitted(
id: PaymentId,
providerReference: String,
): Payment {
val entity = paymentRepository.findById(id.value).orElseThrow { PaymentNotFoundException(id) }
val payment = adapter.toDomain(entity)
payment.transitionTo(PaymentStatus.SUBMITTED)
entity.status = payment.status
entity.providerReference = providerReference
paymentRepository.saveAndFlush(entity)
recordEvent(payment, PaymentEvents.PaymentScreened)
metrics.record(PaymentStatus.SUBMITTED)
return payment
}
@Transactional
override fun markSubmitted(
id: PaymentId,
providerReference: String,
): Payment {
val entity = paymentRepository.findById(id.value).orElseThrow { PaymentNotFoundException(id) }
val payment = adapter.toDomain(entity)
payment.transitionTo(PaymentStatus.SUBMITTED)
entity.status = payment.status
entity.providerReference = providerReference
paymentRepository.saveAndFlush(entity)
recordEvent(payment, PaymentEvents.PaymentScreened)
metrics.record(PaymentStatus.SUBMITTED)
return payment
}

@Transactional
override fun markFailed(
id: PaymentId,
reason: String,
): Payment = transition(id, PaymentStatus.FAILED, PaymentEvents.PaymentFailed, reason)
@Transactional
override fun markFailed(
id: PaymentId,
reason: String,
): Payment = transition(id, PaymentStatus.FAILED, PaymentEvents.PaymentFailed, reason)

@Transactional
override fun markSettled(id: PaymentId): Payment = transition(id, PaymentStatus.SETTLED, PaymentEvents.PaymentSettled)
@Transactional
override fun markSettled(id: PaymentId): Payment = transition(id, PaymentStatus.SETTLED, PaymentEvents.PaymentSettled)

private fun transition(
id: PaymentId,
target: PaymentStatus,
type: EventType,
detail: String? = null,
): Payment {
val entity = paymentRepository.findById(id.value).orElseThrow { PaymentNotFoundException(id) }
val payment = adapter.toDomain(entity)
payment.transitionTo(target)
entity.status = payment.status
paymentRepository.saveAndFlush(entity)
recordEvent(payment, type, detail)
metrics.record(target)
return payment
}
private fun transition(
id: PaymentId,
target: PaymentStatus,
type: EventType,
detail: String? = null,
): Payment {
val entity = paymentRepository.findById(id.value).orElseThrow { PaymentNotFoundException(id) }
val payment = adapter.toDomain(entity)
payment.transitionTo(target)
entity.status = payment.status
paymentRepository.saveAndFlush(entity)
recordEvent(payment, type, detail)
metrics.record(target)
return payment
}

private fun createPayment(command: InitiatePaymentCommand): Payment {
val payment = Payment(PaymentId.generate(), command.amount, command.reference)
paymentRepository.saveAndFlush(adapter.toNewEntity(payment, Instant.now()))
recordEvent(payment, PaymentEvents.PaymentInitiated)
metrics.record(PaymentStatus.INITIATED)
return payment
}
private fun createPayment(command: InitiatePaymentCommand): Payment {
val payment = Payment(PaymentId.generate(), command.amount, command.reference)
paymentRepository.saveAndFlush(adapter.toNewEntity(payment, Instant.now()))
recordEvent(payment, PaymentEvents.PaymentInitiated)
metrics.record(PaymentStatus.INITIATED)
// Must stay inside this transaction: an AFTER_COMMIT listener drives orchestration only if the tx commits.
eventPublisher.publishEvent(PaymentInitiatedEvent(payment.id))
return payment
}

private fun recordEvent(
payment: Payment,
type: EventType,
detail: String? = null,
) {
val now = Instant.now()
val envelope =
EventEnvelope.of(
source = SOURCE,
type = type,
data = PaymentEventData.from(payment, detail),
subject = payment.id.toString(),
private fun recordEvent(
payment: Payment,
type: EventType,
detail: String? = null,
) {
val now = Instant.now()
val envelope =
EventEnvelope.of(
source = SOURCE,
type = type,
data = PaymentEventData.from(payment, detail),
subject = payment.id.toString(),
)
paymentEventRepository.saveAndFlush(
PaymentEventEntity(UUID.randomUUID(), payment.id.value, type.fullType, objectMapper.writeValueAsString(envelope), now),
)
paymentEventRepository.saveAndFlush(
PaymentEventEntity(UUID.randomUUID(), payment.id.value, type.fullType, objectMapper.writeValueAsString(envelope), now),
)
outboxPublisher.publish(envelope, AGGREGATE_TYPE, payment.id.toString(), type.fullType, now)
}
outboxPublisher.publish(envelope, AGGREGATE_TYPE, payment.id.toString(), type.fullType, now)
}

private companion object {
const val MAX_ATTEMPTS = 3
const val SOURCE = "payments"
const val AGGREGATE_TYPE = "Payment"
private companion object {
const val MAX_ATTEMPTS = 3
const val SOURCE = "payments"
const val AGGREGATE_TYPE = "Payment"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.payments.application.event

import com.fincore.core.PaymentId

/** Published in-process once a payment is persisted as INITIATED, to drive orchestration after the transaction commits. */
data class PaymentInitiatedEvent(
val paymentId: PaymentId,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.payments.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor

/** Enables @Async and provides a bounded executor for post-commit payment orchestration. */
@Configuration
@EnableAsync
class PaymentAsyncConfig {
@Bean
fun paymentOrchestrationExecutor(): ThreadPoolTaskExecutor =
ThreadPoolTaskExecutor().apply {
corePoolSize = CORE_POOL_SIZE
maxPoolSize = MAX_POOL_SIZE
setQueueCapacity(QUEUE_CAPACITY)
setThreadNamePrefix("payment-orch-")
setWaitForTasksToCompleteOnShutdown(true)
setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS)
initialize()
}

private companion object {
const val CORE_POOL_SIZE = 2
const val MAX_POOL_SIZE = 8
const val QUEUE_CAPACITY = 100
const val AWAIT_TERMINATION_SECONDS = 20
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.payments.application

import com.fincore.core.PaymentId
import com.fincore.payments.application.event.PaymentInitiatedEvent
import com.fincore.payments.domain.Payment
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test

class PaymentOrchestrationTriggerTest {
private val orchestrator = mockk<PaymentOrchestrator>()
private val trigger = PaymentOrchestrationTrigger(orchestrator)
private val id = PaymentId.generate()

@Test
fun `processes the payment when it is initiated`() {
every { orchestrator.process(id) } returns mockk<Payment>()

trigger.onPaymentInitiated(PaymentInitiatedEvent(id))

verify { orchestrator.process(id) }
}

@Test
fun `swallows an orchestration failure so the async worker survives`() {
every { orchestrator.process(id) } throws RuntimeException("bank down")

trigger.onPaymentInitiated(PaymentInitiatedEvent(id))

verify { orchestrator.process(id) }
}
}
Loading
Loading