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,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" }
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
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.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
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<BankSubmissionResult.Accepted>()
}

@Test
fun `should return a rejected result when the implementation rejects`() {
val provider = FixedBankProvider(BankSubmissionResult.Rejected("declined"))

provider.submit(request).shouldBeInstanceOf<BankSubmissionResult.Rejected>()
}

@Test
fun `should reject a blank payment id or reference`() {
shouldThrow<IllegalArgumentException> { BankPaymentRequest(" ", Money(BigDecimal.ONE, Currency.USD), "order-1") }
shouldThrow<IllegalArgumentException> { BankPaymentRequest("pay_1", Money(BigDecimal.ONE, Currency.USD), " ") }
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Loading