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

package com.fincore.payments.api

import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fincore.payments.api.dto.request.WebhookRequest
import com.fincore.payments.application.webhook.MalformedWebhookException
import com.fincore.payments.application.webhook.PaymentWebhookHandler
import com.fincore.payments.application.webhook.PaymentWebhookNotification
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@Tag(name = "Payment webhooks", description = "Inbound provider callbacks, authenticated by an HMAC signature")
@RestController
@RequestMapping("/v1/payments/webhooks")
class PaymentWebhookController(
private val handler: PaymentWebhookHandler,
private val objectMapper: ObjectMapper,
) {
@Operation(
summary = "Receive a payment webhook",
description = "Verifies the signature over the raw body, then settles or fails the payment.",
)
@PostMapping
fun receive(
@RequestBody rawBody: String,
@RequestHeader(SIGNATURE_HEADER) signature: String,
): ResponseEntity<Unit> {
val request = parse(rawBody)
handler.handle(rawBody, signature, PaymentWebhookNotification(request.deliveryId, request.providerReference, request.outcome))
return ResponseEntity.ok().build()
}

private fun parse(rawBody: String): WebhookRequest =
try {
objectMapper.readValue(rawBody, WebhookRequest::class.java)
} catch (ex: JsonProcessingException) {
throw MalformedWebhookException(ex)
}

private companion object {
const val SIGNATURE_HEADER = "X-Webhook-Signature"
}
}
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.api.dto.request

import com.fincore.payments.application.webhook.WebhookOutcome

data class WebhookRequest(
val deliveryId: String,
val providerReference: String,
val outcome: WebhookOutcome,
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum class ProblemType(
) {
PAYMENT_NOT_FOUND("payment-not-found", HttpStatus.NOT_FOUND, "PAYMENT_NOT_FOUND", "payment not found"),
PAYMENT_CONFLICT("payment-conflict", HttpStatus.CONFLICT, "PAYMENT_CONFLICT", "illegal payment state transition"),
INVALID_SIGNATURE("invalid-signature", HttpStatus.UNAUTHORIZED, "INVALID_SIGNATURE", "invalid webhook signature"),
CONCURRENCY_CONFLICT("concurrency-conflict", HttpStatus.SERVICE_UNAVAILABLE, "CONCURRENCY_CONFLICT", "concurrency conflict, retry"),
VALIDATION_FAILED("validation-failed", HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "invalid request"),
MALFORMED_REQUEST("malformed-request", HttpStatus.BAD_REQUEST, "MALFORMED_REQUEST", "invalid request"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.payments.application.webhook

class MalformedWebhookException(
cause: Throwable,
) : RuntimeException("malformed webhook payload", cause)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package com.fincore.payments.exception

import com.fincore.payments.api.error.ProblemType
import com.fincore.payments.application.PaymentConcurrencyException
import com.fincore.payments.application.webhook.MalformedWebhookException
import com.fincore.payments.application.webhook.WebhookSignatureException
import com.fincore.payments.domain.exception.PaymentDomainException
import com.fincore.payments.domain.exception.PaymentNotFoundException
import jakarta.servlet.http.HttpServletRequest
Expand Down Expand Up @@ -39,6 +41,18 @@ class GlobalExceptionHandler {
request: HttpServletRequest,
): ResponseEntity<ProblemDetail> = retryable(ProblemType.CONCURRENCY_CONFLICT, ex.message, request)

@ExceptionHandler(WebhookSignatureException::class)
fun handleSignature(
ex: WebhookSignatureException,
request: HttpServletRequest,
): ProblemDetail = problem(ProblemType.INVALID_SIGNATURE, ex.message, request)

@ExceptionHandler(MalformedWebhookException::class)
fun handleMalformedWebhook(
ex: MalformedWebhookException,
request: HttpServletRequest,
): ProblemDetail = problem(ProblemType.MALFORMED_REQUEST, ex.message, request)

@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(
ex: MethodArgumentNotValidException,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.payments.api

import com.fincore.payments.application.webhook.PaymentWebhookHandler
import com.fincore.payments.application.webhook.PaymentWebhookNotification
import com.fincore.payments.application.webhook.WebhookOutcome
import com.fincore.payments.application.webhook.WebhookResult
import com.fincore.payments.application.webhook.WebhookSignatureException
import com.fincore.payments.config.SecurityConfig
import com.fincore.payments.exception.GlobalExceptionHandler
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@WebMvcTest(PaymentWebhookController::class)
@Import(SecurityConfig::class, GlobalExceptionHandler::class, PaymentWebhookControllerTest.Mocks::class)
class PaymentWebhookControllerTest(
@Autowired private val mockMvc: MockMvc,
@Autowired private val handler: PaymentWebhookHandler,
) {
@TestConfiguration
class Mocks {
@Bean fun paymentWebhookHandler(): PaymentWebhookHandler = mockk()

@Bean fun jwtDecoder(): JwtDecoder = mockk()
}

@BeforeEach
fun resetMocks() {
clearMocks(handler)
}

@Test
fun `should accept a signed webhook without a bearer token and forward the parsed notification`() {
val notification = slot<PaymentWebhookNotification>()
every { handler.handle(any(), any(), capture(notification)) } returns WebhookResult.Processed

mockMvc
.perform(
post("/v1/payments/webhooks")
.header(SIGNATURE_HEADER, "sig")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_BODY),
).andExpect(status().isOk)

verify { handler.handle(VALID_BODY, "sig", any()) }
notification.captured.deliveryId shouldBe "d-1"
notification.captured.providerReference shouldBe "prov-1"
notification.captured.outcome shouldBe WebhookOutcome.SETTLED
}

@Test
fun `should return 401 when the signature is invalid`() {
every { handler.handle(any(), any(), any()) } throws WebhookSignatureException("invalid webhook signature")

mockMvc
.perform(
post("/v1/payments/webhooks")
.header(SIGNATURE_HEADER, "bad")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_BODY),
).andExpect(status().isUnauthorized)
}

@Test
fun `should return 400 when the body is malformed`() {
mockMvc
.perform(
post("/v1/payments/webhooks")
.header(SIGNATURE_HEADER, "sig")
.contentType(MediaType.APPLICATION_JSON)
.content("{not-json"),
).andExpect(status().isBadRequest)

verify(exactly = 0) { handler.handle(any(), any(), any()) }
}

@Test
fun `should return 400 when the signature header is missing`() {
mockMvc
.perform(post("/v1/payments/webhooks").contentType(MediaType.APPLICATION_JSON).content(VALID_BODY))
.andExpect(status().isBadRequest)
}

private companion object {
const val SIGNATURE_HEADER = "X-Webhook-Signature"
const val VALID_BODY = """{"deliveryId":"d-1","providerReference":"prov-1","outcome":"SETTLED"}"""
}
}
Loading