Skip to content

Commit c7a11fb

Browse files
authored
PIL-2624 - Update BTN to handle raw response from ETMP (#639)
This PR implements the frontend portion of the BTN Audit refactoring (PIL-2624). **Key Changes:** 1. **Raw Response Handling**: The `BTNConnector` and `BTNService` now return the raw `HttpResponse` from the `pillar2` backend, which is acting as a transparent proxy. 2. **Enhanced Auditing**: The `BtnSubmissionService` has been updated to capture the exact status code and response body from the downstream ETMP service. 3. **Audit Model Updates**: Added `ApiResponseData.fromHttpResponse` to correctly parse raw responses for audit events. 4. **Error Handling**: Logic for handling success (201) vs failure (non-201) has been moved here from the backend. 5. **Test Updates**: Comprehensive updates to `BTNConnectorSpec`, `BTNServiceSpec`, `BtnSubmissionServiceSpec`, and `CheckYourAnswersControllerSpec` to mock and verify `HttpResponse` objects and ensure compilation success. **Motivation:** To improve visibility into downstream errors and ensure the audit trail accurately reflects the raw response from ETMP.
1 parent 8498d17 commit c7a11fb

File tree

10 files changed

+164
-144
lines changed

10 files changed

+164
-144
lines changed

app/connectors/BTNConnector.scala

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,9 @@
1616

1717
package connectors
1818

19-
import cats.syntax.either.*
2019
import config.FrontendAppConfig
21-
import models.InternalIssueError
2220
import models.btn.*
2321
import play.api.Logging
24-
import play.api.http.Status.*
2522
import play.api.libs.json.*
2623
import play.api.libs.ws.JsonBodyWritables.writeableOf_JsValue
2724
import uk.gov.hmrc.http.*
@@ -31,12 +28,11 @@ import uk.gov.hmrc.http.{HeaderCarrier, HttpResponse}
3128

3229
import javax.inject.Inject
3330
import scala.concurrent.{ExecutionContext, Future}
34-
import scala.util.Try
3531

3632
class BTNConnector @Inject() (val config: FrontendAppConfig, val httpClientV2: HttpClientV2) extends Logging {
3733
def submitBTN(
3834
btnRequest: BTNRequest
39-
)(using hc: HeaderCarrier, pillar2Id: String, ec: ExecutionContext): Future[BtnResponse] = {
35+
)(using hc: HeaderCarrier, pillar2Id: String, ec: ExecutionContext): Future[HttpResponse] = {
4036
val urlBTN = s"${config.pillar2BaseUrl}/report-pillar2-top-up-taxes/below-threshold-notification/submit"
4137

4238
logger.info(s"Calling pillar-2 backend url = $urlBTN with pillar2Id: $pillar2Id.")
@@ -45,22 +41,5 @@ class BTNConnector @Inject() (val config: FrontendAppConfig, val httpClientV2: H
4541
.withBody(Json.toJson(btnRequest))
4642
.setHeader("X-Pillar2-Id" -> pillar2Id)
4743
.execute[HttpResponse]
48-
.flatMap { (response: HttpResponse) =>
49-
response.status match {
50-
case CREATED =>
51-
logger.info(s"submitBTN request successful with status = ${response.status}. HttpResponse = ${response.body}. ")
52-
Future.fromTry(Try(BtnResponse(response.json.as[BtnSuccess].asRight, httpStatusCode = response.status)))
53-
case BAD_REQUEST | UNPROCESSABLE_ENTITY | INTERNAL_SERVER_ERROR =>
54-
logger.warn(
55-
s"submitBTN failed with handled status = ${response.status} for pillar2Id $pillar2Id and (accountingPeriodFrom, To) = $btnRequest."
56-
)
57-
Future.fromTry(Try(BtnResponse(response.json.as[BtnError].asLeft, httpStatusCode = response.status)))
58-
case _ =>
59-
logger.error(
60-
s"submitBTN failed with unexpected status = ${response.status} for pillar2Id $pillar2Id and (accountingPeriodFrom, To) = $btnRequest."
61-
)
62-
Future.failed(InternalIssueError)
63-
}
64-
}
6544
}
6645
}

app/models/audit/AuditBtn.scala

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616

1717
package models.audit
1818

19+
import models.btn.BTNSuccessResponse
1920
import models.btn.BtnResponse
21+
import models.hip.ApiFailureResponse
2022
import models.subscription.AccountingPeriod
2123
import play.api.libs.functional.syntax.*
2224
import play.api.libs.json.*
25+
import uk.gov.hmrc.http.HttpResponse
2326

2427
import java.time.*
28+
import scala.util.Try
2529

2630
case class CreateBtnAuditEvent(
2731
pillarReference: String,
@@ -74,6 +78,47 @@ object ApiResponseData {
7478
)
7579
}
7680

81+
def fromHttpResponse(response: HttpResponse)(using clock: Clock): ApiResponseData = {
82+
83+
val jsonOpt = Try(response.json).toOption
84+
85+
response.status match {
86+
case 201 =>
87+
val processingDate = jsonOpt
88+
.flatMap(_.validate[BTNSuccessResponse].asOpt)
89+
.map(_.success.processingDate)
90+
.getOrElse(ZonedDateTime.now(clock))
91+
ApiResponseSuccess(response.status, processingDate)
92+
93+
case _ =>
94+
val (errorCode, message) = jsonOpt.flatMap(_.validate[ApiFailureResponse].asOpt) match {
95+
case Some(etmpError) =>
96+
(etmpError.errors.code, etmpError.errors.text)
97+
case None =>
98+
// Fallback for non-422 errors (like 400/500 from API platform/ETMP)
99+
val jsonFallbackCode = jsonOpt
100+
.flatMap(j =>
101+
(j \ "code")
102+
.asOpt[String]
103+
.orElse((j \ "failures" \ 0 \ "code").asOpt[String])
104+
)
105+
.getOrElse("UNKNOWN")
106+
107+
val jsonFallbackMessage = jsonOpt
108+
.flatMap(j =>
109+
(j \ "message")
110+
.asOpt[String]
111+
.orElse((j \ "failures" \ 0 \ "reason").asOpt[String])
112+
)
113+
.getOrElse(response.body)
114+
115+
(jsonFallbackCode, jsonFallbackMessage)
116+
}
117+
118+
ApiResponseFailure(response.status, ZonedDateTime.now(clock), errorCode, message)
119+
}
120+
}
121+
77122
given writes: Writes[ApiResponseData] = {
78123
case success @ ApiResponseSuccess(_, _) => Json.toJson(success)
79124
case failure @ ApiResponseFailure(_, _, _, _) => Json.toJson(failure)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2024 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package models.btn
18+
19+
import play.api.libs.json.{Json, OFormat}
20+
21+
import java.time.ZonedDateTime
22+
23+
case class BTNSuccess(processingDate: ZonedDateTime)
24+
object BTNSuccess {
25+
given format: OFormat[BTNSuccess] = Json.format[BTNSuccess]
26+
}
27+
28+
case class BTNSuccessResponse(success: BTNSuccess)
29+
object BTNSuccessResponse {
30+
given format: OFormat[BTNSuccessResponse] = Json.format[BTNSuccessResponse]
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2024 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package models.hip
18+
19+
import play.api.libs.json.{Json, OFormat}
20+
21+
import java.time.ZonedDateTime
22+
23+
case class ApiFailure(processingDate: ZonedDateTime, code: String, text: String)
24+
object ApiFailure {
25+
given format: OFormat[ApiFailure] = Json.format[ApiFailure]
26+
}
27+
28+
case class ApiFailureResponse(errors: ApiFailure)
29+
object ApiFailureResponse {
30+
given format: OFormat[ApiFailureResponse] = Json.format[ApiFailureResponse]
31+
}

app/services/BTNService.scala

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@
1717
package services
1818

1919
import connectors.*
20-
import models.InternalIssueError
21-
import models.btn.{BTNRequest, BtnResponse}
20+
import models.btn.BTNRequest
2221
import play.api.Logging
23-
import uk.gov.hmrc.http.HeaderCarrier
22+
import uk.gov.hmrc.http.{HeaderCarrier, HttpResponse}
2423

2524
import javax.inject.{Inject, Singleton}
2625
import scala.concurrent.{ExecutionContext, Future}
@@ -31,24 +30,6 @@ class BTNService @Inject() (
3130
)(using ec: ExecutionContext)
3231
extends Logging {
3332

34-
def submitBTN(btnRequest: BTNRequest)(using headerCarrier: HeaderCarrier, pillar2Id: String): Future[BtnResponse] =
35-
btnConnector
36-
.submitBTN(btnRequest)
37-
.map { btnResponse =>
38-
btnResponse.result match {
39-
case Left(failure) =>
40-
logger.info(
41-
s"BTN Request Submission failed with ${failure.errorCode}: ${failure.message}"
42-
)
43-
case Right(success) =>
44-
logger.info(
45-
s"BTN Request Submission was successful. Processed ${success.processingDate}"
46-
)
47-
}
48-
btnResponse
49-
}
50-
.recoverWith { case ex: Throwable =>
51-
logger.warn(s"BTNService Request failed with an exception: " + ex)
52-
Future.failed(InternalIssueError)
53-
}
33+
def submitBTN(btnRequest: BTNRequest)(using headerCarrier: HeaderCarrier, pillar2Id: String): Future[HttpResponse] =
34+
btnConnector.submitBTN(btnRequest)
5435
}

app/services/BtnSubmissionService.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ class BtnSubmissionService @Inject() (
7777
.flatMap { resp =>
7878
sessionRepository.get(userId).flatMap {
7979
case Some(latest) =>
80-
resp.result match {
81-
case Right(_) =>
80+
resp.status match {
81+
case 201 =>
8282
for {
8383
submittedAnswers <- Future.fromTry {
8484
latest
@@ -90,18 +90,18 @@ class BtnSubmissionService @Inject() (
9090
pillarReference = pillar2Id,
9191
accountingPeriod = accountingPeriod,
9292
entitiesInsideAndOutsideUK = originalAnswers.get(EntitiesInsideOutsideUKPage).getOrElse(false),
93-
response = ApiResponseData.fromBtnResponse(resp)(using clock)
93+
response = ApiResponseData.fromHttpResponse(resp)(using clock)
9494
)
9595
} yield ()
96-
case Left(_) =>
96+
case _ =>
9797
for {
9898
errorAnswers <- Future.fromTry(latest.set(BTNStatus, BTNStatus.error))
9999
_ <- sessionRepository.set(errorAnswers)
100100
_ <- auditService.auditBTNSubmission(
101101
pillarReference = pillar2Id,
102102
accountingPeriod = accountingPeriod,
103103
entitiesInsideAndOutsideUK = originalAnswers.get(EntitiesInsideOutsideUKPage).getOrElse(false),
104-
response = ApiResponseData.fromBtnResponse(resp)(using clock)
104+
response = ApiResponseData.fromHttpResponse(resp)(using clock)
105105
)
106106
} yield ()
107107
}

test/connectors/BTNConnectorSpec.scala

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
package connectors
1818

1919
import base.{SpecBase, WireMockServerHandler}
20-
import cats.syntax.either.*
21-
import models.InternalIssueError
2220
import models.btn.*
2321
import org.scalacheck.Arbitrary.arbitrary
2422
import org.scalacheck.Gen
@@ -29,7 +27,7 @@ import play.api.inject.guice.GuiceApplicationBuilder
2927
import play.api.libs.json.*
3028
import uk.gov.hmrc.http.test.WireMockSupport
3129

32-
import java.time.{LocalDate, ZonedDateTime}
30+
import java.time.LocalDate
3331

3432
class BTNConnectorSpec extends SpecBase with WireMockSupport with WireMockServerHandler with EitherValues with ScalaCheckDrivenPropertyChecks {
3533
override lazy val app: Application = new GuiceApplicationBuilder()
@@ -41,23 +39,22 @@ class BTNConnectorSpec extends SpecBase with WireMockSupport with WireMockServer
4139
accountingPeriodFrom = LocalDate.now.minusYears(1),
4240
accountingPeriodTo = LocalDate.now
4341
)
44-
val rawProcessedDateTime: String = "2022-01-31T09:26:17Z"
45-
val stubProcessedDateTime: ZonedDateTime = ZonedDateTime.parse(rawProcessedDateTime)
46-
val successfulBTNResponseBody: JsObject = Json.obj("processingDate" -> rawProcessedDateTime)
47-
val successfulBtnResponse: BtnResponse = BtnResponse(BtnSuccess(stubProcessedDateTime).asRight, CREATED)
48-
val accountingPeriodFromDateMinus1Year: LocalDate = LocalDate.now.minusYears(1)
49-
val accountingPeriodToDateNow: LocalDate = LocalDate.now
50-
val btnRequestDatesMinus1YearAndNow: BTNRequest = BTNRequest(accountingPeriodFromDateMinus1Year, accountingPeriodToDateNow)
42+
val rawProcessedDateTime: String = "2022-01-31T09:26:17Z"
43+
val successfulBTNResponseBody: JsObject = Json.obj("processingDate" -> rawProcessedDateTime)
44+
val accountingPeriodFromDateMinus1Year: LocalDate = LocalDate.now.minusYears(1)
45+
val accountingPeriodToDateNow: LocalDate = LocalDate.now
46+
val btnRequestDatesMinus1YearAndNow: BTNRequest = BTNRequest(accountingPeriodFromDateMinus1Year, accountingPeriodToDateNow)
5147

5248
"submit BTN connector" should {
53-
"return a BtnSuccess when the pillar-2 backend has returned status 201." in {
49+
"return the response when the pillar-2 backend has returned status 201." in {
5450
given pillar2Id: String = "XEPLR0000000000"
5551
stubResponse(submitBTNPath, CREATED, successfulBTNResponseBody.toString())
5652
val result = connector.submitBTN(btnRequestDatesMinus1YearAndNow).futureValue
57-
result mustBe successfulBtnResponse
53+
result.status mustBe CREATED
54+
result.body mustBe successfulBTNResponseBody.toString()
5855
}
5956

60-
"surface the failure details when dealing with a modelled 400 or 500 error" in forAll(
57+
"return the response when dealing with a 400 or 500 error" in forAll(
6158
Gen.oneOf(BAD_REQUEST, INTERNAL_SERVER_ERROR),
6259
arbitrary[String],
6360
arbitrary[String]
@@ -69,13 +66,11 @@ class BTNConnectorSpec extends SpecBase with WireMockSupport with WireMockServer
6966
given pillar2Id: String = "XEPLR0000000000"
7067
stubResponse(submitBTNPath, httpStatusCode, jsonResponse.toString())
7168
val result = connector.submitBTN(btnRequestDatesMinus1YearAndNow).futureValue
72-
result mustBe BtnResponse(
73-
BtnError(errorCode, errorMessage).asLeft,
74-
httpStatusCode
75-
)
69+
result.status mustBe httpStatusCode
70+
result.body mustBe jsonResponse.toString()
7671
}
7772

78-
"surface the failure details when dealing with a modelled 422 error" in forAll { (errorCode: String, errorMessage: String) =>
73+
"return the response when dealing with a 422 error" in forAll { (errorCode: String, errorMessage: String) =>
7974
val jsonResponse = Json.obj(
8075
"processingDate" -> rawProcessedDateTime,
8176
"code" -> errorCode,
@@ -84,26 +79,17 @@ class BTNConnectorSpec extends SpecBase with WireMockSupport with WireMockServer
8479
given pillar2Id: String = "XEPLR0000000000"
8580
stubResponse(submitBTNPath, UNPROCESSABLE_ENTITY, jsonResponse.toString())
8681
val result = connector.submitBTN(btnRequestDatesMinus1YearAndNow).futureValue
87-
result mustBe BtnResponse(
88-
BtnError(errorCode, errorMessage).asLeft,
89-
UNPROCESSABLE_ENTITY
90-
)
82+
result.status mustBe UNPROCESSABLE_ENTITY
83+
result.body mustBe jsonResponse.toString()
9184
}
9285

93-
"raise an Exception when the expected response field-value-checking fails." in {
94-
given pillar2Id: String = "XEPLR9999999999"
95-
stubResponse(submitBTNPath, CREATED, Json.obj().toString())
96-
val result = connector.submitBTN(btnRequestDatesMinus1YearAndNow).failed.futureValue
97-
result mustBe JsResultException(Seq(((__ \ "processingDate"), Seq(JsonValidationError(Seq("error.path.missing"))))))
98-
}
99-
100-
"return InternalIssueError when the pillar-2 backend returns any unsupported status." in forAll(
86+
"return the response even when the pillar-2 backend returns any unsupported status." in forAll(
10187
Gen.posNum[Int].retryUntil(code => !Seq(CREATED, BAD_REQUEST, UNPROCESSABLE_ENTITY, INTERNAL_SERVER_ERROR).contains(code))
10288
) { (httpStatus: Int) =>
10389
given pillar2Id: String = "XEPLR4000000000"
10490
stubResponse(submitBTNPath, httpStatus, successfulBTNResponseBody.toString())
105-
val result = connector.submitBTN(btnRequestDatesMinus1YearAndNow)
106-
result.failed.futureValue mustBe InternalIssueError
91+
val result = connector.submitBTN(btnRequestDatesMinus1YearAndNow).futureValue
92+
result.status mustBe httpStatus
10793
}
10894
}
10995
}

0 commit comments

Comments
 (0)