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
1 change: 1 addition & 0 deletions services/payments/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}}"""
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,6 @@ class PaymentEntity(
@Version
@Column(name = "version", nullable = false)
var version: Long,
@Column(name = "provider_reference")
var providerReference: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<PaymentService>(relaxed = true)
private val screeningEvaluator = mockk<ScreeningEvaluator>()
private val bankProvider = mockk<BankProvider>()
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<BankPaymentRequest>()) }
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<BankProviderException> { 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)
}
Loading
Loading