diff --git a/services/payments/src/main/kotlin/com/fincore/payments/api/PaymentWebhookController.kt b/services/payments/src/main/kotlin/com/fincore/payments/api/PaymentWebhookController.kt new file mode 100644 index 0000000..9eacfe9 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/api/PaymentWebhookController.kt @@ -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 { + 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" + } +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/api/dto/request/WebhookRequest.kt b/services/payments/src/main/kotlin/com/fincore/payments/api/dto/request/WebhookRequest.kt new file mode 100644 index 0000000..89e6ea6 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/api/dto/request/WebhookRequest.kt @@ -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, +) diff --git a/services/payments/src/main/kotlin/com/fincore/payments/api/error/ProblemType.kt b/services/payments/src/main/kotlin/com/fincore/payments/api/error/ProblemType.kt index 7f6dcbd..8bc2efb 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/api/error/ProblemType.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/api/error/ProblemType.kt @@ -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"), diff --git a/services/payments/src/main/kotlin/com/fincore/payments/application/webhook/MalformedWebhookException.kt b/services/payments/src/main/kotlin/com/fincore/payments/application/webhook/MalformedWebhookException.kt new file mode 100644 index 0000000..0f9cfc9 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/application/webhook/MalformedWebhookException.kt @@ -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) diff --git a/services/payments/src/main/kotlin/com/fincore/payments/exception/GlobalExceptionHandler.kt b/services/payments/src/main/kotlin/com/fincore/payments/exception/GlobalExceptionHandler.kt index 6100736..04d7020 100644 --- a/services/payments/src/main/kotlin/com/fincore/payments/exception/GlobalExceptionHandler.kt +++ b/services/payments/src/main/kotlin/com/fincore/payments/exception/GlobalExceptionHandler.kt @@ -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 @@ -39,6 +41,18 @@ class GlobalExceptionHandler { request: HttpServletRequest, ): ResponseEntity = 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, diff --git a/services/payments/src/test/kotlin/com/fincore/payments/api/PaymentWebhookControllerTest.kt b/services/payments/src/test/kotlin/com/fincore/payments/api/PaymentWebhookControllerTest.kt new file mode 100644 index 0000000..4d97520 --- /dev/null +++ b/services/payments/src/test/kotlin/com/fincore/payments/api/PaymentWebhookControllerTest.kt @@ -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() + 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"}""" + } +}