diff --git a/services/payments/build.gradle.kts b/services/payments/build.gradle.kts index 1dfd38a..c5dffbc 100644 --- a/services/payments/build.gradle.kts +++ b/services/payments/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":libs:fincore-core")) implementation(project(":libs:fincore-events")) implementation(project(":libs:fincore-eventbus")) + implementation(project(":libs:decision-engine")) implementation(libs.kotlin.stdlib) implementation(libs.kotlin.reflect) diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentEventData.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentEventData.kt index 53765a9..9a26df9 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentEventData.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentEventData.kt @@ -11,15 +11,20 @@ data class PaymentEventData( val amount: String, val currency: String, val status: String, + val detail: String? = null, ) { companion object { - fun from(payment: Payment): PaymentEventData = + fun from( + payment: Payment, + detail: String? = null, + ): PaymentEventData = PaymentEventData( paymentId = payment.id.toString(), reference = payment.reference, amount = payment.amount.amount.toPlainString(), currency = payment.amount.currency.code, status = payment.status.name, + detail = detail, ) } } 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 new file mode 100644 index 0000000..002b095 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentOrchestrator.kt @@ -0,0 +1,46 @@ +// 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.bank.BankPaymentRequest +import com.fincore.payments.application.bank.BankProvider +import com.fincore.payments.application.bank.BankSubmissionResult +import com.fincore.payments.application.screening.ScreeningDecision +import com.fincore.payments.application.screening.ScreeningEvaluator +import com.fincore.payments.domain.Payment +import org.springframework.stereotype.Service + +/** + * Drives an initiated payment through screening and bank submission. NOT transactional: each persisted transition is + * its own short transaction on [PaymentService], and the external [BankProvider.submit] runs between them, never + * inside a transaction (CLAUDE.md 8.10). A [com.fincore.payments.application.bank.BankProviderException] from the bank + * propagates and leaves the payment in SCREENING for the scheduled retry to re-attempt. + */ +@Service +class PaymentOrchestrator( + private val paymentService: PaymentService, + private val screeningEvaluator: ScreeningEvaluator, + private val bankProvider: BankProvider, +) { + fun process(id: PaymentId): Payment { + val screened = paymentService.screen(id) + return when (val decision = screeningEvaluator.evaluate(screened)) { + ScreeningDecision.Approve -> route(id, screened) + is ScreeningDecision.Decline -> paymentService.markFailed(id, decision.reason) + } + } + + private fun route( + id: PaymentId, + payment: Payment, + ): Payment = + when (val result = bankProvider.submit(toBankRequest(payment))) { + is BankSubmissionResult.Accepted -> paymentService.markSubmitted(id, result.providerReference) + is BankSubmissionResult.Rejected -> paymentService.markFailed(id, result.reason) + } + + private fun toBankRequest(payment: Payment): BankPaymentRequest = + BankPaymentRequest(payment.id.toString(), payment.amount, payment.reference) +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentService.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentService.kt index bd159b9..3f336a2 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentService.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentService.kt @@ -10,4 +10,16 @@ interface PaymentService { fun initiate(command: InitiatePaymentCommand): Payment fun cancel(id: PaymentId): Payment + + fun screen(id: PaymentId): Payment + + fun markSubmitted( + id: PaymentId, + providerReference: String, + ): Payment + + fun markFailed( + id: PaymentId, + reason: String, + ): Payment } diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentServiceImpl.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentServiceImpl.kt index 4018039..58e73f4 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentServiceImpl.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/PaymentServiceImpl.kt @@ -44,13 +44,51 @@ class PaymentServiceImpl( } @Transactional - override fun cancel(id: PaymentId): Payment { + 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) + 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) + return payment + } + + @Transactional + override fun markFailed( + id: PaymentId, + reason: String, + ): Payment = transition(id, PaymentStatus.FAILED, PaymentEvents.PaymentFailed, reason) + + 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(PaymentStatus.CANCELLED) + payment.transitionTo(target) entity.status = payment.status paymentRepository.saveAndFlush(entity) - recordEvent(payment, PaymentEvents.PaymentCancelled) + recordEvent(payment, type, detail) return payment } @@ -64,13 +102,14 @@ class PaymentServiceImpl( 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), + data = PaymentEventData.from(payment, detail), subject = payment.id.toString(), ) paymentEventRepository.saveAndFlush( diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/screening/PaymentScreeningProperties.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/screening/PaymentScreeningProperties.kt new file mode 100644 index 0000000..781fa6c --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/screening/PaymentScreeningProperties.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.screening + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "fincore.payments.screening") +data class PaymentScreeningProperties( + val rule: String = DEFAULT_RULE, + val approveLabel: String = DEFAULT_APPROVE_LABEL, +) { + private companion object { + const val DEFAULT_APPROVE_LABEL = "APPROVE" + const val DEFAULT_RULE = + """{"condition":{"attr":"amount","op":"gte","value":0},"outcome":{"label":"APPROVE"}}""" + } +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/screening/ScreeningDecision.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/screening/ScreeningDecision.kt new file mode 100644 index 0000000..ce20df6 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/screening/ScreeningDecision.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.screening + +sealed interface ScreeningDecision { + data object Approve : ScreeningDecision + + data class Decline( + val reason: String, + ) : ScreeningDecision +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/screening/ScreeningEvaluator.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/screening/ScreeningEvaluator.kt new file mode 100644 index 0000000..466ef0b --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/screening/ScreeningEvaluator.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.screening + +import com.fincore.decision.domain.DecimalValue +import com.fincore.decision.domain.DecisionResult +import com.fincore.decision.domain.DecisionRule +import com.fincore.decision.domain.EvaluationInput +import com.fincore.decision.domain.StringValue +import com.fincore.decision.eval.RuleEvaluator +import com.fincore.decision.parser.RuleParser +import com.fincore.payments.domain.Payment +import org.springframework.stereotype.Component + +/** + * Screens a payment in-process with the embedded decision engine. The configured rule is parsed fail-closed at + * construction, so an invalid rule fails startup rather than silently approving. + */ +@Component +class ScreeningEvaluator( + properties: PaymentScreeningProperties, +) { + private val rule: DecisionRule = RuleParser().parse(properties.rule) + private val approveLabel: String = properties.approveLabel + private val evaluator = RuleEvaluator() + + fun evaluate(payment: Payment): ScreeningDecision { + val input = + EvaluationInput( + mapOf( + "amount" to DecimalValue(payment.amount.amount), + "currency" to StringValue(payment.amount.currency.code), + "reference" to StringValue(payment.reference), + ), + ) + val result = evaluator.evaluate(rule, input) + return if (result.matched && result.outcome?.label == approveLabel) { + ScreeningDecision.Approve + } else { + ScreeningDecision.Decline(declineReason(result)) + } + } + + private fun declineReason(result: DecisionResult): String = + result.outcome + ?.reasonCodes + ?.takeIf { it.isNotEmpty() } + ?.joinToString(",") ?: DECLINED + + private companion object { + const val DECLINED = "screening declined" + } +} 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 new file mode 100644 index 0000000..dea12ae --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/config/PaymentsConfig.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.config + +import com.fincore.payments.application.screening.PaymentScreeningProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(PaymentScreeningProperties::class) +class PaymentsConfig diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEntity.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEntity.kt index 3f16241..428d2bd 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEntity.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEntity.kt @@ -36,4 +36,6 @@ class PaymentEntity( @Version @Column(name = "version", nullable = false) var version: Long, + @Column(name = "provider_reference") + var providerReference: String? = null, ) 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 3a82c0a..1bb013a 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 @@ -19,3 +19,6 @@ databaseChangeLog: - include: file: v0.1/014-idempotency-keys.sql relativeToChangelogFile: true + - include: + file: v0.1/015-payments-provider-reference.sql + relativeToChangelogFile: true diff --git a/services/payments/src/main/resources/db/changelog/v0.1/015-payments-provider-reference.sql b/services/payments/src/main/resources/db/changelog/v0.1/015-payments-provider-reference.sql new file mode 100644 index 0000000..cb2b6c3 --- /dev/null +++ b/services/payments/src/main/resources/db/changelog/v0.1/015-payments-provider-reference.sql @@ -0,0 +1,10 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:payments-015-provider-reference dbms:postgresql +ALTER TABLE payments.payments + ADD COLUMN IF NOT EXISTS provider_reference VARCHAR(200); +CREATE INDEX IF NOT EXISTS idx_payments_provider_reference + ON payments.payments(provider_reference) + WHERE provider_reference IS NOT NULL; diff --git a/services/payments/src/test/kotlin/com/fincore/payments/application/PaymentOrchestratorTest.kt b/services/payments/src/test/kotlin/com/fincore/payments/application/PaymentOrchestratorTest.kt new file mode 100644 index 0000000..714d813 --- /dev/null +++ b/services/payments/src/test/kotlin/com/fincore/payments/application/PaymentOrchestratorTest.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application + +import com.fincore.core.Currency +import com.fincore.core.Money +import com.fincore.core.PaymentId +import com.fincore.payments.application.bank.BankPaymentRequest +import com.fincore.payments.application.bank.BankProvider +import com.fincore.payments.application.bank.BankProviderException +import com.fincore.payments.application.bank.BankSubmissionResult +import com.fincore.payments.application.screening.ScreeningDecision +import com.fincore.payments.application.screening.ScreeningEvaluator +import com.fincore.payments.domain.Payment +import com.fincore.payments.domain.enum.PaymentStatus +import io.kotest.assertions.throwables.shouldThrow +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class PaymentOrchestratorTest { + private val paymentService = mockk(relaxed = true) + private val screeningEvaluator = mockk() + private val bankProvider = mockk() + private val orchestrator = PaymentOrchestrator(paymentService, screeningEvaluator, bankProvider) + + private val id = PaymentId.generate() + + init { + every { paymentService.screen(id) } returns payment(PaymentStatus.SCREENING) + } + + @Test + fun `should submit to the bank and mark submitted when approved and accepted`() { + every { screeningEvaluator.evaluate(any()) } returns ScreeningDecision.Approve + every { bankProvider.submit(any()) } returns BankSubmissionResult.Accepted("ref-1") + + orchestrator.process(id) + + verify { bankProvider.submit(any()) } + verify { paymentService.markSubmitted(id, "ref-1") } + } + + @Test + fun `should mark failed when approved but the bank rejects`() { + every { screeningEvaluator.evaluate(any()) } returns ScreeningDecision.Approve + every { bankProvider.submit(any()) } returns BankSubmissionResult.Rejected("declined") + + orchestrator.process(id) + + verify { paymentService.markFailed(id, "declined") } + } + + @Test + fun `should mark failed without calling the bank when screening declines`() { + every { screeningEvaluator.evaluate(any()) } returns ScreeningDecision.Decline("screening declined") + + orchestrator.process(id) + + verify { paymentService.markFailed(id, "screening declined") } + verify(exactly = 0) { bankProvider.submit(any()) } + } + + @Test + fun `should leave the payment screening when the bank fails technically`() { + every { screeningEvaluator.evaluate(any()) } returns ScreeningDecision.Approve + every { bankProvider.submit(any()) } throws BankProviderException("timeout") + + shouldThrow { orchestrator.process(id) } + + verify(exactly = 0) { paymentService.markSubmitted(any(), any()) } + verify(exactly = 0) { paymentService.markFailed(any(), any()) } + } + + private fun payment(status: PaymentStatus): Payment = Payment(id, Money(BigDecimal("100.00"), Currency.USD), "order-1", status) +} diff --git a/services/payments/src/test/kotlin/com/fincore/payments/application/screening/ScreeningEvaluatorTest.kt b/services/payments/src/test/kotlin/com/fincore/payments/application/screening/ScreeningEvaluatorTest.kt new file mode 100644 index 0000000..6ee4490 --- /dev/null +++ b/services/payments/src/test/kotlin/com/fincore/payments/application/screening/ScreeningEvaluatorTest.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.screening + +import com.fincore.core.Currency +import com.fincore.core.Money +import com.fincore.core.PaymentId +import com.fincore.decision.domain.DecisionDslException +import com.fincore.payments.domain.Payment +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class ScreeningEvaluatorTest { + @Test + fun `should approve when the default rule matches the payment`() { + val evaluator = ScreeningEvaluator(PaymentScreeningProperties()) + + evaluator.evaluate(payment()) shouldBe ScreeningDecision.Approve + } + + @Test + fun `should decline when the configured rule does not match`() { + val rule = """{"condition":{"attr":"amount","op":"gte","value":1000000},"outcome":{"label":"APPROVE"}}""" + val evaluator = ScreeningEvaluator(PaymentScreeningProperties(rule = rule)) + + evaluator.evaluate(payment()).shouldBeInstanceOf() + } + + @Test + fun `should fail construction when the configured rule is invalid`() { + shouldThrow { ScreeningEvaluator(PaymentScreeningProperties(rule = "{not-valid")) } + } + + private fun payment(): Payment = Payment(PaymentId.generate(), Money(BigDecimal("100.00"), Currency.USD), "order-1") +}