From 72390ea4f25ba0daf3977af6834c5363605d8d34 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Thu, 18 Jun 2026 10:23:01 -0300 Subject: [PATCH] feat(payments): add bank provider port and sandbox adapter Add a generic, interface-only BankProvider plug-in port: submit takes a bank payment request and returns a sealed accept/reject result, with technical failures surfaced as an exception so a bank decision is never confused with a transport error. Add a deterministic sandbox adapter, off by default, that accepts or rejects based on a reference marker so the lifecycle can be driven end to end without any real bank. Real adapters live out of tree. Closes #212 --- .../application/bank/BankPaymentRequest.kt | 17 ++++++ .../payments/application/bank/BankProvider.kt | 19 +++++++ .../application/bank/BankProviderException.kt | 10 ++++ .../application/bank/BankSubmissionResult.kt | 21 +++++++ .../bank/SandboxBankProvider.kt | 30 ++++++++++ .../bank/BankProviderContractTest.kt | 55 +++++++++++++++++++ .../bank/SandboxBankProviderTest.kt | 40 ++++++++++++++ 7 files changed, 192 insertions(+) create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankPaymentRequest.kt create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankProvider.kt create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankProviderException.kt create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankSubmissionResult.kt create mode 100644 services/payments/src/main/kotlin/com/fincore/payments/infrastructure/bank/SandboxBankProvider.kt create mode 100644 services/payments/src/test/kotlin/com/fincore/payments/application/bank/BankProviderContractTest.kt create mode 100644 services/payments/src/test/kotlin/com/fincore/payments/infrastructure/bank/SandboxBankProviderTest.kt diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankPaymentRequest.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankPaymentRequest.kt new file mode 100644 index 0000000..57bb7e3 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankPaymentRequest.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.bank + +import com.fincore.core.Money + +data class BankPaymentRequest( + val paymentId: String, + val amount: Money, + val reference: String, +) { + init { + require(paymentId.isNotBlank()) { "paymentId must not be blank" } + require(reference.isNotBlank()) { "reference must not be blank" } + } +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankProvider.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankProvider.kt new file mode 100644 index 0000000..7d063a5 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankProvider.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.bank + +/** + * Plug-in port for submitting a payment to an external bank. + * + * Real bank adapters are provided out of tree and are NOT part of this open-source service; the only in-tree + * implementation is a deterministic sandbox. The contract is generic and encodes no real-bank protocol, field, + * or business value. + * + * Implementations perform no persistence; the caller decides what to do with the result, outside any transaction. + * A bank decision is returned as a [BankSubmissionResult]; a technical or transient failure is thrown as a + * [BankProviderException]. + */ +interface BankProvider { + fun submit(request: BankPaymentRequest): BankSubmissionResult +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankProviderException.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankProviderException.kt new file mode 100644 index 0000000..0e10c35 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankProviderException.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.bank + +/** Signals a technical or transient failure submitting to a bank, distinct from a business rejection. */ +class BankProviderException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankSubmissionResult.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankSubmissionResult.kt new file mode 100644 index 0000000..a308512 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/bank/BankSubmissionResult.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.bank + +/** + * Outcome of submitting a payment to a [BankProvider]. A business decision (accept/reject), distinct from a + * technical failure (which is a thrown [BankProviderException]). + * + * [Accepted] means accepted-for-processing at submission, NOT final settlement; the final state arrives + * asynchronously (provider webhook). + */ +sealed interface BankSubmissionResult { + data class Accepted( + val providerReference: String, + ) : BankSubmissionResult + + data class Rejected( + val reason: String, + ) : BankSubmissionResult +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/bank/SandboxBankProvider.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/bank/SandboxBankProvider.kt new file mode 100644 index 0000000..c367b69 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/bank/SandboxBankProvider.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.bank + +import com.fincore.payments.application.bank.BankPaymentRequest +import com.fincore.payments.application.bank.BankProvider +import com.fincore.payments.application.bank.BankSubmissionResult +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty( + prefix = "fincore.payments.bank.sandbox", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false, +) +class SandboxBankProvider : BankProvider { + override fun submit(request: BankPaymentRequest): BankSubmissionResult = + if (request.reference.contains(REJECT_MARKER, ignoreCase = true)) { + BankSubmissionResult.Rejected("sandbox rejected by reference marker") + } else { + BankSubmissionResult.Accepted("sbx-${request.paymentId}") + } + + private companion object { + const val REJECT_MARKER = "reject" + } +} diff --git a/services/payments/src/test/kotlin/com/fincore/payments/application/bank/BankProviderContractTest.kt b/services/payments/src/test/kotlin/com/fincore/payments/application/bank/BankProviderContractTest.kt new file mode 100644 index 0000000..9ef3e28 --- /dev/null +++ b/services/payments/src/test/kotlin/com/fincore/payments/application/bank/BankProviderContractTest.kt @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.application.bank + +import com.fincore.core.Currency +import com.fincore.core.Money +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 + +private class FixedBankProvider( + private val result: BankSubmissionResult, +) : BankProvider { + override fun submit(request: BankPaymentRequest): BankSubmissionResult = result +} + +class BankProviderContractTest { + private val request = BankPaymentRequest("pay_1", Money(BigDecimal("100.00"), Currency.USD), "order-1") + + @Test + fun `should expose a single submit method when inspected`() { + BankProvider::class.java.isInterface shouldBe true + + val method = + BankProvider::class.java.declaredMethods + .filter { !it.isBridge && !it.isSynthetic } + .single { it.name == "submit" } + + method.returnType shouldBe BankSubmissionResult::class.java + method.parameterTypes.toList() shouldBe listOf(BankPaymentRequest::class.java) + } + + @Test + fun `should return an accepted result when the implementation accepts`() { + val provider = FixedBankProvider(BankSubmissionResult.Accepted("ref-1")) + + provider.submit(request).shouldBeInstanceOf() + } + + @Test + fun `should return a rejected result when the implementation rejects`() { + val provider = FixedBankProvider(BankSubmissionResult.Rejected("declined")) + + provider.submit(request).shouldBeInstanceOf() + } + + @Test + fun `should reject a blank payment id or reference`() { + shouldThrow { BankPaymentRequest(" ", Money(BigDecimal.ONE, Currency.USD), "order-1") } + shouldThrow { BankPaymentRequest("pay_1", Money(BigDecimal.ONE, Currency.USD), " ") } + } +} diff --git a/services/payments/src/test/kotlin/com/fincore/payments/infrastructure/bank/SandboxBankProviderTest.kt b/services/payments/src/test/kotlin/com/fincore/payments/infrastructure/bank/SandboxBankProviderTest.kt new file mode 100644 index 0000000..61c4031 --- /dev/null +++ b/services/payments/src/test/kotlin/com/fincore/payments/infrastructure/bank/SandboxBankProviderTest.kt @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.bank + +import com.fincore.core.Currency +import com.fincore.core.Money +import com.fincore.payments.application.bank.BankPaymentRequest +import com.fincore.payments.application.bank.BankSubmissionResult +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class SandboxBankProviderTest { + private val provider = SandboxBankProvider() + + @Test + fun `should accept with a deterministic provider reference when the reference has no reject marker`() { + val result = provider.submit(request("order-1")) + + result shouldBe BankSubmissionResult.Accepted("sbx-pay_1") + } + + @Test + fun `should reject when the reference carries the reject marker`() { + val result = provider.submit(request("please-REJECT-this")) + + result shouldBe BankSubmissionResult.Rejected("sandbox rejected by reference marker") + } + + @Test + fun `should produce the same result when the same request is submitted twice`() { + val request = request("order-1") + + provider.submit(request) shouldBe provider.submit(request) + } + + private fun request(reference: String): BankPaymentRequest = + BankPaymentRequest("pay_1", Money(BigDecimal("100.00"), Currency.USD), reference) +}