diff --git a/app/config/Module.scala b/app/config/Module.scala index 67d9d1b5..79a1f21d 100755 --- a/app/config/Module.scala +++ b/app/config/Module.scala @@ -36,6 +36,7 @@ class Module extends AbstractModule { bind(classOf[JourneyAnswersRepository]).to(classOf[MongoJourneyAnswersRepository]) bind(classOf[SelfEmploymentConnector]).to(classOf[SelfEmploymentConnectorImpl]) bind(classOf[JourneyStatusService]).to(classOf[JourneyStatusServiceImpl]) + bind(classOf[NICsAnswersService]).to(classOf[NICsAnswersServiceImpl]) () } diff --git a/app/connectors/SelfEmploymentConnectorImpl.scala b/app/connectors/SelfEmploymentConnectorImpl.scala index 550e13ac..fbb38da2 100644 --- a/app/connectors/SelfEmploymentConnectorImpl.scala +++ b/app/connectors/SelfEmploymentConnectorImpl.scala @@ -16,6 +16,7 @@ package connectors +import cats.data.EitherT import com.typesafe.config.ConfigFactory import config.AppConfig import connectors.SelfEmploymentConnector._ @@ -23,10 +24,13 @@ import models.common.TaxYear.{asTys, endDate, startDate} import models.common._ import models.connector.IntegrationContext.IFSHeaderCarrier import models.connector._ +import models.connector.api_1638.RequestSchemaAPI1638 +import models.connector.api_1639.SuccessResponseAPI1639 import models.connector.api_1802.request.{CreateAmendSEAnnualSubmissionRequestBody, CreateAmendSEAnnualSubmissionRequestData} import models.connector.api_1894.request.{CreateSEPeriodSummaryRequestBody, CreateSEPeriodSummaryRequestData} import models.connector.api_1895.request.{AmendSEPeriodSummaryRequestBody, AmendSEPeriodSummaryRequestData} -import uk.gov.hmrc.http.{HeaderCarrier, HttpClient} +import models.domain.ApiResultT +import uk.gov.hmrc.http.{HeaderCarrier, HttpClient, HttpReads} import utils.Logging import javax.inject.{Inject, Singleton} @@ -43,6 +47,13 @@ trait SelfEmploymentConnector { def createSEPeriodSummary(data: CreateSEPeriodSummaryRequestData)(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Api1894Response] def amendSEPeriodSummary(data: AmendSEPeriodSummaryRequestData)(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Api1895Response] def listSEPeriodSummary(ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Api1965Response] + + def getDisclosuresSubmission( + ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier, ec: ExecutionContext): ApiResultT[Option[SuccessResponseAPI1639]] + def upsertDisclosuresSubmission(ctx: JourneyContextWithNino, data: RequestSchemaAPI1638)(implicit + hc: HeaderCarrier, + ec: ExecutionContext): ApiResultT[Unit] + def deleteDisclosuresSubmission(ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier, ec: ExecutionContext): ApiResultT[Unit] } object SelfEmploymentConnector { @@ -53,6 +64,8 @@ object SelfEmploymentConnector { type Api1894Response = ApiResponse[api_1894.response.CreateSEPeriodSummaryResponse] type Api1895Response = ApiResponse[api_1895.response.AmendSEPeriodSummaryResponse] type Api1965Response = ApiResponse[api_1965.ListSEPeriodSummariesResponse] + type Api1638Response = ApiResponse[Unit] + type Api1639Response = ApiResponseOption[SuccessResponseAPI1639] } @Singleton @@ -75,6 +88,9 @@ class SelfEmploymentConnectorImpl @Inject() (http: HttpClient, appConfig: AppCon private def periodicSummaryDetailUrl(nino: Nino, incomeSourceId: BusinessId, taxYear: TaxYear) = s"${baseUrl(nino, incomeSourceId, taxYear)}/periodic-summary-detail?from=${startDate(taxYear)}&to=${endDate(taxYear)}" + private def disclosuresSubmissionUrl(nino: Nino, taxYear: TaxYear) = + s"${appConfig.ifsBaseUrl}/income-tax/disclosures/$nino/${taxYear.toYYYY_YY}" + // TODO Move to GetBusinessDetailsConnector def getBusinesses(idType: IdType, idNumber: String)(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Api1171Response] = { val context = mkIFSMetadata(IFSApiName.Api1171, api1171BusinessDetailsUrl(idType, idNumber)) @@ -117,4 +133,32 @@ class SelfEmploymentConnectorImpl @Inject() (http: HttpClient, appConfig: AppCon val context = mkIFSMetadata(IFSApiName.Api1803, url) get[Api1803Response](http, context) } + + def getDisclosuresSubmission( + ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier, ec: ExecutionContext): ApiResultT[Option[SuccessResponseAPI1639]] = { + val url = disclosuresSubmissionUrl(ctx.nino, ctx.taxYear) + val context = mkIFSMetadata(IFSApiName.Api1639, url) + implicit val reads: HttpReads[ApiResponse[Option[SuccessResponseAPI1639]]] = commonGetReads[SuccessResponseAPI1639] + + EitherT(get[Api1639Response](http, context)) + } + + def upsertDisclosuresSubmission(ctx: JourneyContextWithNino, data: RequestSchemaAPI1638)(implicit + hc: HeaderCarrier, + ec: ExecutionContext): ApiResultT[Unit] = { + val url = disclosuresSubmissionUrl(ctx.nino, ctx.taxYear) + val context = mkIFSMetadata(IFSApiName.Api1638, url) + implicit val reads: HttpReads[ApiResponse[Unit]] = commonNoBodyResponse + + EitherT(put[RequestSchemaAPI1638, Api1638Response](http, context, data)) + } + + def deleteDisclosuresSubmission(ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier, ec: ExecutionContext): ApiResultT[Unit] = { + val url = disclosuresSubmissionUrl(ctx.nino, ctx.taxYear) + val context = mkIFSMetadata(IFSApiName.Api1640, url) + implicit val reads: HttpReads[ApiResponse[Unit]] = commonDeleteReads + + EitherT(delete(http, context)) + } + } diff --git a/app/connectors/package.scala b/app/connectors/package.scala index 70fe7834..7f1e11a5 100644 --- a/app/connectors/package.scala +++ b/app/connectors/package.scala @@ -52,4 +52,13 @@ package object connectors { ConnectorRequestInfo("PUT", context.url, context.api).logRequestWithBody(logger, body) http.PUT[Req, Resp](context.url, body)(writes, reads, context.enrichedHeaderCarrier, ec) } + + def delete[Resp: HttpReads](http: HttpClient, context: IntegrationContext)(implicit + hc: HeaderCarrier, + ec: ExecutionContext, + logger: Logger): Future[Resp] = { + val reads = implicitly[HttpReads[Resp]] + ConnectorRequestInfo("DELETE", context.url, context.api).logRequest(logger) + http.DELETE[Resp](context.url)(reads, context.enrichedHeaderCarrier, ec) + } } diff --git a/app/controllers/JourneyAnswersController.scala b/app/controllers/JourneyAnswersController.scala index 88e5fdd0..4aa4557b 100644 --- a/app/controllers/JourneyAnswersController.scala +++ b/app/controllers/JourneyAnswersController.scala @@ -54,6 +54,7 @@ import models.frontend.expenses.tailoring.ExpensesTailoring.TotalAmount import models.frontend.expenses.tailoring.ExpensesTailoringAnswers._ import models.frontend.expenses.workplaceRunningCosts.WorkplaceRunningCostsAnswers import models.frontend.income.IncomeJourneyAnswers +import models.frontend.nics.NICsAnswers import play.api.libs.json.Format.GenericFormat import play.api.libs.json.{Reads, Writes} import play.api.mvc.{Action, AnyContent, ControllerComponents, Result} @@ -72,7 +73,8 @@ class JourneyAnswersController @Inject() (auth: AuthorisedAction, abroadAnswersService: AbroadAnswersService, incomeService: IncomeAnswersService, expensesService: ExpensesAnswersService, - capitalAllowancesService: CapitalAllowancesAnswersService)(implicit ec: ExecutionContext) + capitalAllowancesService: CapitalAllowancesAnswersService, + nicsAnswersService: NICsAnswersService)(implicit ec: ExecutionContext) extends BackendController(cc) with Logging { @@ -422,4 +424,14 @@ class JourneyAnswersController @Inject() (auth: AuthorisedAction, handleOptionalApiResult(capitalAllowancesService.getStructuresBuildings(JourneyContextWithNino(taxYear, businessId, user.getMtditid, nino))) } + def saveNationalInsuranceContributions(taxYear: TaxYear, businessId: BusinessId, nino: Nino): Action[AnyContent] = auth.async { implicit user => + getBodyWithCtx[NICsAnswers](taxYear, businessId, nino) { (ctx, answers) => + nicsAnswersService.saveAnswers(ctx, answers).map(_ => NoContent) + } + } + + def getNationalInsuranceContributions(taxYear: TaxYear, businessId: BusinessId, nino: Nino): Action[AnyContent] = auth.async { implicit user => + handleOptionalApiResult(nicsAnswersService.getAnswers(JourneyContextWithNino(taxYear, businessId, user.getMtditid, nino))) + } + } diff --git a/app/models/common/TaxYear.scala b/app/models/common/TaxYear.scala index 8fa46564..53909baa 100644 --- a/app/models/common/TaxYear.scala +++ b/app/models/common/TaxYear.scala @@ -23,6 +23,8 @@ import java.time.LocalDate final case class TaxYear(endYear: Int) extends AnyVal { override def toString: String = endYear.toString + + def toYYYY_YY: String = s"${endYear - 1}-${endYear.toString.takeRight(2)}" } object TaxYear { diff --git a/app/models/connector/IFSApiName.scala b/app/models/connector/IFSApiName.scala index 2620ccef..1570762a 100644 --- a/app/models/connector/IFSApiName.scala +++ b/app/models/connector/IFSApiName.scala @@ -26,6 +26,9 @@ object IFSApiName extends Enum[IFSApiName] { val values = IndexedSeq[IFSApiName]() case object Api1171 extends IFSApiName("1171") + case object Api1638 extends IFSApiName("1638") + case object Api1639 extends IFSApiName("1639") + case object Api1640 extends IFSApiName("1640") case object Api1786 extends IFSApiName("1786") case object Api1802 extends IFSApiName("1802") case object Api1803 extends IFSApiName("1803") diff --git a/app/models/connector/api_1638/RequestSchemaAPI1638.scala b/app/models/connector/api_1638/RequestSchemaAPI1638.scala new file mode 100644 index 00000000..dfc6e562 --- /dev/null +++ b/app/models/connector/api_1638/RequestSchemaAPI1638.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.connector.api_1638 + +import models.connector.api_1639.SuccessResponseAPI1639 +import models.frontend.nics.NICsAnswers +import play.api.libs.json._ + +/** Represents the Swagger definition for requestSchemaAPI1638. + */ +case class RequestSchemaAPI1638( + taxAvoidance: Option[List[RequestSchemaAPI1638TaxAvoidanceInner]], + class2Nics: Option[RequestSchemaAPI1638Class2Nics] +) + +object RequestSchemaAPI1638 { + implicit lazy val requestSchemaAPI1638JsonFormat: Format[RequestSchemaAPI1638] = Json.format[RequestSchemaAPI1638] + + /** Some(false) is not possible to set on class2VoluntaryContributions. We cannot send an empty object therefore if there are no other fields we + * have to call DELETE. + * + * @return + * None if the object needs to be DELETED or Some() if it needs to be updated via PUT + */ + def mkRequestBody(answers: NICsAnswers, maybeExistingDisclosures: Option[SuccessResponseAPI1639]): Option[RequestSchemaAPI1638] = { + val existingDisclosures = maybeExistingDisclosures.getOrElse(SuccessResponseAPI1639.empty) + + val bodyForPut = RequestSchemaAPI1638( + taxAvoidance = existingDisclosures.taxAvoidance.map { taxAvoidance => + taxAvoidance.map { taxAvoidanceInner => + RequestSchemaAPI1638TaxAvoidanceInner( + srn = taxAvoidanceInner.srn, + taxYear = taxAvoidanceInner.taxYear + ) + } + }, + class2Nics = existingDisclosures.class2Nics.map { class2Nics => + RequestSchemaAPI1638Class2Nics( + class2VoluntaryContributions = class2Nics.class2VoluntaryContributions + ) + } + ) + + if (answers.class2NICs) { + Some(bodyForPut.copy(class2Nics = Some(RequestSchemaAPI1638Class2Nics(Some(true))))) + } else if (bodyForPut.taxAvoidance.isDefined) { + Some(bodyForPut.copy(class2Nics = None)) + } else { + None + } + } +} diff --git a/app/models/connector/api_1638/RequestSchemaAPI1638Class2Nics.scala b/app/models/connector/api_1638/RequestSchemaAPI1638Class2Nics.scala new file mode 100644 index 00000000..38b8e2ab --- /dev/null +++ b/app/models/connector/api_1638/RequestSchemaAPI1638Class2Nics.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.connector.api_1638 + +import play.api.libs.json._ + +/** Voluntary contributions. + */ +case class RequestSchemaAPI1638Class2Nics( + class2VoluntaryContributions: Option[Boolean] +) + +object RequestSchemaAPI1638Class2Nics { + implicit lazy val requestSchemaAPI1638Class2NicsJsonFormat: Format[RequestSchemaAPI1638Class2Nics] = Json.format[RequestSchemaAPI1638Class2Nics] +} diff --git a/app/models/connector/api_1638/RequestSchemaAPI1638TaxAvoidanceInner.scala b/app/models/connector/api_1638/RequestSchemaAPI1638TaxAvoidanceInner.scala new file mode 100644 index 00000000..c7e251ba --- /dev/null +++ b/app/models/connector/api_1638/RequestSchemaAPI1638TaxAvoidanceInner.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.connector.api_1638 + +import play.api.libs.json._ + +/** Represents the Swagger definition for requestSchemaAPI1638_taxAvoidance_inner. + */ +case class RequestSchemaAPI1638TaxAvoidanceInner( + srn: String, + taxYear: String +) + +object RequestSchemaAPI1638TaxAvoidanceInner { + implicit lazy val requestSchemaAPI1638TaxAvoidanceInnerJsonFormat: Format[RequestSchemaAPI1638TaxAvoidanceInner] = + Json.format[RequestSchemaAPI1638TaxAvoidanceInner] +} diff --git a/app/models/connector/api_1639/SuccessResponseAPI1639.scala b/app/models/connector/api_1639/SuccessResponseAPI1639.scala new file mode 100644 index 00000000..a4255708 --- /dev/null +++ b/app/models/connector/api_1639/SuccessResponseAPI1639.scala @@ -0,0 +1,32 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.connector.api_1639 + +import play.api.libs.json._ + +/** Represents the Swagger definition for successResponseAPI1639. + */ +case class SuccessResponseAPI1639( + taxAvoidance: Option[List[SuccessResponseAPI1639TaxAvoidanceInner]], + class2Nics: Option[SuccessResponseAPI1639Class2Nics] +) + +object SuccessResponseAPI1639 { + implicit lazy val successResponseAPI1639JsonFormat: Format[SuccessResponseAPI1639] = Json.format[SuccessResponseAPI1639] + + def empty: SuccessResponseAPI1639 = SuccessResponseAPI1639(None, None) +} diff --git a/app/models/connector/api_1639/SuccessResponseAPI1639Class2Nics.scala b/app/models/connector/api_1639/SuccessResponseAPI1639Class2Nics.scala new file mode 100644 index 00000000..3fd9efc4 --- /dev/null +++ b/app/models/connector/api_1639/SuccessResponseAPI1639Class2Nics.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.connector.api_1639 + +import play.api.libs.json._ + +/** Voluntary contributions. + */ +case class SuccessResponseAPI1639Class2Nics( + class2VoluntaryContributions: Option[Boolean] +) + +object SuccessResponseAPI1639Class2Nics { + implicit lazy val successResponseAPI1639Class2NicsJsonFormat: Format[SuccessResponseAPI1639Class2Nics] = + Json.format[SuccessResponseAPI1639Class2Nics] +} diff --git a/app/models/connector/api_1639/SuccessResponseAPI1639TaxAvoidanceInner.scala b/app/models/connector/api_1639/SuccessResponseAPI1639TaxAvoidanceInner.scala new file mode 100644 index 00000000..aa798609 --- /dev/null +++ b/app/models/connector/api_1639/SuccessResponseAPI1639TaxAvoidanceInner.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.connector.api_1639 + +import play.api.libs.json._ + +/** Represents the Swagger definition for successResponseAPI1639_taxAvoidance_inner. + */ +case class SuccessResponseAPI1639TaxAvoidanceInner( + srn: String, + taxYear: String +) + +object SuccessResponseAPI1639TaxAvoidanceInner { + implicit lazy val successResponseAPI1639TaxAvoidanceInnerJsonFormat: Format[SuccessResponseAPI1639TaxAvoidanceInner] = + Json.format[SuccessResponseAPI1639TaxAvoidanceInner] +} diff --git a/app/models/connector/package.scala b/app/models/connector/package.scala index 48e81788..12935825 100644 --- a/app/models/connector/package.scala +++ b/app/models/connector/package.scala @@ -21,33 +21,72 @@ import connectors.DownstreamParser.CommonDownstreamParser import models.error.DownstreamError import models.logging.ConnectorResponseInfo import play.api.Logger -import play.api.http.Status.{CREATED, OK} +import play.api.http.Status._ import play.api.libs.json._ import uk.gov.hmrc.http.{HttpReads, HttpResponse} import java.time.format.DateTimeFormatter package object connector { - type ApiResponse[A] = Either[DownstreamError, A] + type ApiResponse[A] = Either[DownstreamError, A] + type ApiResponseOption[A] = Either[DownstreamError, Option[A]] val dateFormatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE - implicit def httpReads[A: Reads](implicit logger: Logger): HttpReads[ApiResponse[A]] = (method: String, url: String, response: HttpResponse) => { + /** It treats any non OK / CREATED / ACCEPTED as an error + */ + implicit def commonReads[A: Reads](implicit logger: Logger): HttpReads[ApiResponse[A]] = (method: String, url: String, response: HttpResponse) => { ConnectorResponseInfo(method, url, response).logResponseWarnOn4xx(logger) response.status match { - case OK | CREATED => - response.json - .validate[A] - .fold[Either[DownstreamError, A]]( - errors => Left(createCommonErrorParser(method, url, response).reportInvalidJsonError(errors.toList)), - parsedModel => Right(parsedModel) - ) - - case _ => Left(createCommonErrorParser(method, url, response).pagerDutyError(response)) + case OK | CREATED | ACCEPTED => toA(response, method, url) + case _ => Left(createCommonErrorParser(method, url, response).pagerDutyError(response)) } } + /** It treats any non OK / CREATED / NO_CONTENT / ACCEPTED as an error, and return Unit otherwise + */ + def commonNoBodyResponse(implicit logger: Logger): HttpReads[ApiResponse[Unit]] = (method: String, url: String, response: HttpResponse) => { + ConnectorResponseInfo(method, url, response).logResponseWarnOn4xx(logger) + + response.status match { + case OK | NO_CONTENT | CREATED | ACCEPTED => Right(()) + case _ => Left(createCommonErrorParser(method, url, response).pagerDutyError(response)) + } + } + + /** It treats NOT_FOUND / OK / CREATED / ACCEPTED as correct response and returns None + */ + def commonGetReads[A: Reads](implicit logger: Logger): HttpReads[ApiResponse[Option[A]]] = + (method: String, url: String, response: HttpResponse) => { + ConnectorResponseInfo(method, url, response).logResponseWarnOn4xx(logger) + + response.status match { + case OK | CREATED | ACCEPTED => toA(response, method, url).map(Some(_)) + case NOT_FOUND | NO_CONTENT => Right(None) + case _ => Left(createCommonErrorParser(method, url, response).pagerDutyError(response)) + } + } + + /** It treats NOT_FOUND / NO_CONTENT / OK / ACCEPTED as correct response and returns None + */ + def commonDeleteReads(implicit logger: Logger): HttpReads[ApiResponse[Unit]] = (method: String, url: String, response: HttpResponse) => { + ConnectorResponseInfo(method, url, response).logResponseWarnOn4xx(logger) + + response.status match { + case NOT_FOUND | NO_CONTENT | OK | ACCEPTED => Right(()) + case _ => Left(createCommonErrorParser(method, url, response).pagerDutyError(response)) + } + } + + private def toA[A: Reads](response: HttpResponse, method: String, url: String): Either[DownstreamError, A] = + response.json + .validate[A] + .fold[Either[DownstreamError, A]]( + errors => Left(createCommonErrorParser(method, url, response).reportInvalidJsonError(errors.toList)), + parsedModel => Right(parsedModel) + ) + private def createCommonErrorParser(method: String, url: String, response: HttpResponse): DownstreamParser = CommonDownstreamParser(method, url, response) } diff --git a/app/models/database/nics/NICsStorageAnswers.scala b/app/models/database/nics/NICsStorageAnswers.scala new file mode 100644 index 00000000..58ce9aae --- /dev/null +++ b/app/models/database/nics/NICsStorageAnswers.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.database.nics + +import models.frontend.nics.NICsAnswers +import play.api.libs.json.{Json, OFormat} + +/** The API does not support false. We store only false here. + */ +final case class NICsStorageAnswers(class2NICs: Option[Boolean]) + +object NICsStorageAnswers { + implicit val format: OFormat[NICsStorageAnswers] = Json.format[NICsStorageAnswers] + + /** We send class2=true to API, so we don't store it. We don't send false, so we must store it based on our rule to store everything not sent + * downstream + */ + def fromJourneyAnswers(answers: NICsAnswers): NICsStorageAnswers = + if (answers.class2NICs) NICsStorageAnswers(None) else NICsStorageAnswers(Some(false)) +} diff --git a/app/models/frontend/nics/NICsAnswers.scala b/app/models/frontend/nics/NICsAnswers.scala new file mode 100644 index 00000000..65145c57 --- /dev/null +++ b/app/models/frontend/nics/NICsAnswers.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.frontend.nics + +import models.connector.api_1639.SuccessResponseAPI1639 +import models.database.nics.NICsStorageAnswers +import play.api.libs.json.{Format, Json} + +case class NICsAnswers(class2NICs: Boolean) + +object NICsAnswers { + implicit val formats: Format[NICsAnswers] = Json.format[NICsAnswers] + + def mkPriorData(maybeApiAnswers: Option[SuccessResponseAPI1639], maybeDbAnswers: Option[NICsStorageAnswers]): Option[NICsAnswers] = { + val existingClass2Nics = for { + answers <- maybeApiAnswers + nicsAnswers <- answers.class2Nics + class2Nics <- nicsAnswers.class2VoluntaryContributions + } yield class2Nics + + val maybeNics = existingClass2Nics.map(NICsAnswers(_)) + maybeNics.orElse(maybeDbAnswers.flatMap(_.class2NICs.map(NICsAnswers(_)))) + } + +} diff --git a/app/repositories/MongoJourneyAnswersRepository.scala b/app/repositories/MongoJourneyAnswersRepository.scala index 9da80bb6..d0a3af54 100644 --- a/app/repositories/MongoJourneyAnswersRepository.scala +++ b/app/repositories/MongoJourneyAnswersRepository.scala @@ -31,7 +31,8 @@ import org.mongodb.scala.model.Projections.exclude import org.mongodb.scala.model._ import org.mongodb.scala.result.UpdateResult import play.api.Logger -import play.api.libs.json.{JsValue, Json} +import play.api.libs.json.{JsValue, Json, Reads} +import services.journeyAnswers.getPersistedAnswers import uk.gov.hmrc.mongo.MongoComponent import uk.gov.hmrc.mongo.play.json.PlayMongoRepository import utils.Logging @@ -40,9 +41,11 @@ import java.time.{Clock, Instant, ZoneOffset} import java.util.concurrent.TimeUnit import javax.inject.{Inject, Singleton} import scala.concurrent.{ExecutionContext, Future} +import scala.reflect.ClassTag trait JourneyAnswersRepository { def get(ctx: JourneyContext): ApiResultT[Option[JourneyAnswers]] + def getAnswers[A: Reads](ctx: JourneyContext)(implicit ct: ClassTag[A]): ApiResultT[Option[A]] def getAll(taxYear: TaxYear, mtditid: Mtditid, businesses: List[Business]): ApiResultT[TaskList] def upsertAnswers(ctx: JourneyContext, newData: JsValue): ApiResultT[Unit] def setStatus(ctx: JourneyContext, status: JourneyStatus): ApiResultT[Unit] @@ -100,6 +103,12 @@ class MongoJourneyAnswersRepository @Inject() (mongo: MongoComponent, appConfig: .headOption()) } + def getAnswers[A: Reads](ctx: JourneyContext)(implicit ct: ClassTag[A]): ApiResultT[Option[A]] = + for { + row <- get(ctx) + maybeDbAnswers <- getPersistedAnswers[A](row) + } yield maybeDbAnswers + def getAll(taxYear: TaxYear, mtditid: Mtditid, businesses: List[Business]): ApiResultT[TaskList] = { val filter = filterAllJourneys(taxYear, mtditid) val projection = exclude("data") diff --git a/app/services/journeyAnswers/NICsAnswersService.scala b/app/services/journeyAnswers/NICsAnswersService.scala new file mode 100644 index 00000000..a23a1afb --- /dev/null +++ b/app/services/journeyAnswers/NICsAnswersService.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services.journeyAnswers + +import cats.implicits.toFunctorOps +import connectors.SelfEmploymentConnector +import models.common._ +import models.connector.api_1638.RequestSchemaAPI1638 +import models.database.nics.NICsStorageAnswers +import models.domain.ApiResultT +import models.frontend.nics.NICsAnswers +import play.api.libs.json.Json +import repositories.JourneyAnswersRepository +import uk.gov.hmrc.http.HeaderCarrier + +import javax.inject.{Inject, Singleton} +import scala.concurrent.ExecutionContext + +trait NICsAnswersService { + def saveAnswers(ctx: JourneyContextWithNino, answers: NICsAnswers)(implicit hc: HeaderCarrier): ApiResultT[Unit] + def getAnswers(ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier): ApiResultT[Option[NICsAnswers]] +} + +@Singleton +class NICsAnswersServiceImpl @Inject() (connector: SelfEmploymentConnector, repository: JourneyAnswersRepository)(implicit ec: ExecutionContext) + extends NICsAnswersService { + + def saveAnswers(ctx: JourneyContextWithNino, answers: NICsAnswers)(implicit hc: HeaderCarrier): ApiResultT[Unit] = + for { + existingAnswers <- connector.getDisclosuresSubmission(ctx) + upsertRequest = RequestSchemaAPI1638.mkRequestBody(answers, existingAnswers) + _ <- upsertOrDeleteData(upsertRequest, ctx) + storageAnswers = NICsStorageAnswers.fromJourneyAnswers(answers) + _ <- repository.upsertAnswers(ctx.toJourneyContext(JourneyName.NationalInsuranceContributions), Json.toJson(storageAnswers)) + } yield () + + def getAnswers(ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier): ApiResultT[Option[NICsAnswers]] = + for { + apiAnswers <- connector.getDisclosuresSubmission(ctx) + dbAnswers <- repository.getAnswers[NICsStorageAnswers](ctx.toJourneyContext(JourneyName.NationalInsuranceContributions)) + } yield NICsAnswers.mkPriorData(apiAnswers, dbAnswers) + + private def upsertOrDeleteData(maybeClass2Nics: Option[RequestSchemaAPI1638], ctx: JourneyContextWithNino)(implicit + hc: HeaderCarrier): ApiResultT[Unit] = + maybeClass2Nics match { + case Some(data) => connector.upsertDisclosuresSubmission(ctx, data).void + case None => connector.deleteDisclosuresSubmission(ctx) + } + +} diff --git a/build.sh b/build.sh index be4594d5..37ded957 100755 --- a/build.sh +++ b/build.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -sbt clean scalafmtAll scalafmtSbt compile +sbt clean scalafmtAll scalafmtSbt compile test:compile it:compile diff --git a/conf/app.routes b/conf/app.routes index 0e0fbccc..92366c1c 100755 --- a/conf/app.routes +++ b/conf/app.routes @@ -96,3 +96,5 @@ GET /:taxYear/:businessId/capital-allowances-special-tax-sites/:nino/ans POST /:taxYear/:businessId/capital-allowances-structures-buildings/:nino/answers controllers.JourneyAnswersController.saveStructuresBuildings(taxYear: TaxYear, businessId: BusinessId, nino: Nino) GET /:taxYear/:businessId/capital-allowances-structures-buildings/:nino/answers controllers.JourneyAnswersController.getStructuresBuildings(taxYear: TaxYear, businessId: BusinessId, nino: Nino) +POST /:taxYear/:businessId/national-insurance-contributions/:nino/answers controllers.JourneyAnswersController.saveNationalInsuranceContributions(taxYear: TaxYear, businessId: BusinessId, nino: Nino) +GET /:taxYear/:businessId/national-insurance-contributions/:nino/answers controllers.JourneyAnswersController.getNationalInsuranceContributions(taxYear: TaxYear, businessId: BusinessId, nino: Nino) diff --git a/conf/application.conf b/conf/application.conf index 2d12c582..7d810c6e 100755 --- a/conf/application.conf +++ b/conf/application.conf @@ -83,6 +83,9 @@ microservice { port = 9303 #This is the port for the income-tax-submission-stub authorisation-token { 1171 = "secret" + 1638 = "secret" + 1639 = "secret" + 1640 = "secret" 1786 = "secret" 1802 = "secret" 1803 = "secret" diff --git a/it/connectors/SelfEmploymentConnectorImplISpec.scala b/it/connectors/SelfEmploymentConnectorImplISpec.scala index d96d1876..a81bc671 100644 --- a/it/connectors/SelfEmploymentConnectorImplISpec.scala +++ b/it/connectors/SelfEmploymentConnectorImplISpec.scala @@ -22,6 +22,8 @@ import connectors.data.{Api1786Test, Api1803Test} import helpers.WiremockSpec import models.common.JourneyContextWithNino import models.common.TaxYear.{asTys, endDate, startDate} +import models.connector.api_1638.{RequestSchemaAPI1638, RequestSchemaAPI1638Class2Nics} +import models.connector.api_1639.{SuccessResponseAPI1639, SuccessResponseAPI1639Class2Nics} import models.connector.api_1802.request.{CreateAmendSEAnnualSubmissionRequestBody, CreateAmendSEAnnualSubmissionRequestData} import models.connector.api_1802.response.CreateAmendSEAnnualSubmissionResponse import models.connector.api_1894.request._ @@ -29,6 +31,7 @@ import models.connector.api_1894.response.CreateSEPeriodSummaryResponse import models.connector.api_1895.request.{AmendSEPeriodSummaryRequestBody, AmendSEPeriodSummaryRequestData, Incomes} import models.connector.api_1895.response.AmendSEPeriodSummaryResponse import models.connector.api_1965.{ListSEPeriodSummariesResponse, PeriodDetails} +import org.scalatest.EitherValues._ import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper import play.api.http.Status.{CREATED, OK} import play.api.libs.json.Json @@ -122,6 +125,49 @@ class SelfEmploymentConnectorImplISpec extends WiremockSpec with IntegrationBase } } + "getDisclosuresSubmission" must { + "return None when no data" in { + val result = connector.getDisclosuresSubmission(ctx).value.futureValue + assert(result === Right(None)) + } + + "return existing data" in new ApiDisclosuresTest { + stubGetWithResponseBody( + url = downstreamUrl, + expectedStatus = OK, + expectedResponse = responseJson + ) + val result = connector.getDisclosuresSubmission(ctx).value.futureValue.value + assert(result === Some(SuccessResponseAPI1639(None, Some(SuccessResponseAPI1639Class2Nics(Some(true)))))) + } + } + + "upsertDisclosuresSubmission" must { + "return unit" in new ApiDisclosuresTest { + val request: RequestSchemaAPI1638 = RequestSchemaAPI1638(None, Some(RequestSchemaAPI1638Class2Nics(Some(true)))) + stubPutWithRequestAndResponseBody( + url = downstreamUrl, + requestBody = request, + expectedResponse = "", + expectedStatus = CREATED + ) + val result = connector.upsertDisclosuresSubmission(ctx, request).value.futureValue + assert(result === Right(())) + } + } + + "deleteDisclosuresSubmission" must { + "return unit" in new ApiDisclosuresTest { + stubDelete( + url = downstreamUrl, + expectedResponse = "", + expectedStatus = OK + ) + val result = connector.deleteDisclosuresSubmission(ctx).value.futureValue + assert(result === Right(())) + } + } + trait Api1802Test { val downstreamSuccessResponse: String = Json.stringify(Json.obj("transactionReference" -> "someId")) val requestBody: CreateAmendSEAnnualSubmissionRequestBody = CreateAmendSEAnnualSubmissionRequestBody(None, None, None) @@ -177,4 +223,16 @@ class SelfEmploymentConnectorImplISpec extends WiremockSpec with IntegrationBase data.taxYear)}&to=${endDate(data.taxYear)}" } + trait ApiDisclosuresTest { + val responseJson: String = Json.stringify(Json.parse(s""" + |{ + | "class2Nics": { + | "class2VoluntaryContributions": true + | } + |} + |""".stripMargin)) + + val downstreamUrl = s"/income-tax/disclosures/${ctx.nino.value}/${ctx.taxYear.toYYYY_YY}" + } + } diff --git a/it/helpers/WiremockStubHelpers.scala b/it/helpers/WiremockStubHelpers.scala index 5b5d0d3f..bf0f096a 100644 --- a/it/helpers/WiremockStubHelpers.scala +++ b/it/helpers/WiremockStubHelpers.scala @@ -100,6 +100,21 @@ trait WiremockStubHelpers { .withHeader("Content-Type", "application/json; charset=utf-8"))) } + def stubDelete(url: String, expectedResponse: String, expectedStatus: Int, requestHeaders: Seq[HttpHeader] = Seq.empty): StubMapping = { + val mapping: MappingBuilder = requestHeaders + .foldLeft(delete(urlMatching(url))) { (result, nxt) => + result.withHeader(nxt.key(), equalTo(nxt.firstValue())) + } + + stubFor( + mapping + .willReturn( + aResponse() + .withStatus(expectedStatus) + .withBody(expectedResponse) + .withHeader("Content-Type", "application/json; charset=utf-8"))) + } + def auditStubs(): Unit = { val auditResponseCode = 204 stubPostWithoutResponseAndRequestBody("/write/audit", auditResponseCode) diff --git a/scalastyle-config.xml b/scalastyle-config.xml index c981835d..aa26bbd7 100644 --- a/scalastyle-config.xml +++ b/scalastyle-config.xml @@ -96,11 +96,6 @@ - - - - - diff --git a/test/controllers/JourneyAnswersControllerSpec.scala b/test/controllers/JourneyAnswersControllerSpec.scala index d4847862..529daafd 100644 --- a/test/controllers/JourneyAnswersControllerSpec.scala +++ b/test/controllers/JourneyAnswersControllerSpec.scala @@ -45,6 +45,7 @@ import models.frontend.expenses.repairsandmaintenance.RepairsAndMaintenanceCosts import models.frontend.expenses.staffcosts.StaffCostsJourneyAnswers import models.frontend.expenses.tailoring.ExpensesTailoringAnswers._ import models.frontend.expenses.workplaceRunningCosts.WorkplaceRunningCostsAnswers +import models.frontend.nics.NICsAnswers import models.frontend.prepop.AdjustmentsPrepopAnswers.fromAnnualAdjustmentsType import org.scalacheck.Gen import org.scalamock.handlers.{CallHandler2, CallHandler3} @@ -62,15 +63,24 @@ import scala.concurrent.Future class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckPropertyChecks with TableDrivenPropertyChecks { - val underTest = new JourneyAnswersController( - auth = mockAuthorisedAction, - cc = stubControllerComponents, - abroadAnswersService = StubAbroadAnswersService(), - incomeService = StubIncomeAnswersService(), - expensesService = StubExpensesAnswersService(), - capitalAllowancesService = StubCapitalAllowancesAnswersAnswersService(), - prepopAnswersService = StubPrepopAnswersService() - ) + private def mkUnderTest(abroadAnswersService: StubAbroadAnswersService = StubAbroadAnswersService(), + incomeService: StubIncomeAnswersService = StubIncomeAnswersService(), + expensesService: StubExpensesAnswersService = StubExpensesAnswersService(), + capitalAllowancesService: StubCapitalAllowancesAnswersAnswersService = StubCapitalAllowancesAnswersAnswersService(), + prepopAnswersService: StubPrepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService: StubNICsAnswersService = StubNICsAnswersService()): JourneyAnswersController = + new JourneyAnswersController( + auth = mockAuthorisedAction, + cc = stubControllerComponents, + abroadAnswersService = abroadAnswersService, + incomeService = incomeService, + expensesService = expensesService, + capitalAllowancesService = capitalAllowancesService, + prepopAnswersService = prepopAnswersService, + nicsAnswersService = nicsAnswersService + ) + + val underTest = mkUnderTest() private def checkNoContent(action: Action[AnyContent]): Unit = behave like testRoute( @@ -88,18 +98,38 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP incomeService = StubIncomeAnswersService(), expensesService = StubExpensesAnswersService(), capitalAllowancesService = capitalAllowancesService, - prepopAnswersService = StubPrepopAnswersService() + prepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService = StubNICsAnswersService() ) + // TODO It's better to use lower testNoContent + testGetReturnAnswers + testSaveAnswers directly private def checkGetAndSave[A: Writes](actionForGetNoContent: Action[AnyContent], actionForGet: Action[AnyContent], expectedBodyForGet: String, dataGen: Gen[A], actionForSave: Action[AnyContent]): Unit = { + + testNoContent(actionForGetNoContent) + testGetReturnAnswers(actionForGet, expectedBodyForGet) + + s"Save answers and return a $NO_CONTENT when successful" in { + forAll(dataGen) { data => + behave like testRoute( + request = buildRequest(data), + expectedStatus = NO_CONTENT, + expectedBody = "", + methodBlock = () => actionForSave + ) + } + } + } + + def testNoContent(actionForGetNoContent: Action[AnyContent]): Unit = s"Get return $NO_CONTENT if there is no answers" in { checkNoContent(actionForGetNoContent) } + def testGetReturnAnswers(actionForGet: Action[AnyContent], expectedBodyForGet: String): Unit = s"Get return answers" in { behave like testRoute( request = buildRequestNoContent, @@ -109,17 +139,15 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP ) } + def testSaveAnswers[A: Writes](actionForSave: Action[AnyContent], data: A): Unit = s"Save answers and return a $NO_CONTENT when successful" in { - forAll(dataGen) { data => - behave like testRoute( - request = buildRequest(data), - expectedStatus = NO_CONTENT, - expectedBody = "", - methodBlock = () => actionForSave - ) - } + behave like testRoute( + request = buildRequest(data), + expectedStatus = NO_CONTENT, + expectedBody = "", + methodBlock = () => actionForSave + ) } - } "SelfEmploymentAbroadAnswers" should { s"Get return $NO_CONTENT if there is no answers" in { @@ -135,7 +163,8 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP incomeService = StubIncomeAnswersService(), expensesService = StubExpensesAnswersService(), capitalAllowancesService = StubCapitalAllowancesAnswersAnswersService(), - prepopAnswersService = StubPrepopAnswersService() + prepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService = StubNICsAnswersService() ) behave like testRoute( @@ -170,7 +199,8 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP incomeService = StubIncomeAnswersService(getAnswersRes = Some(answers).asRight), expensesService = StubExpensesAnswersService(), capitalAllowancesService = StubCapitalAllowancesAnswersAnswersService(), - prepopAnswersService = StubPrepopAnswersService() + prepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService = StubNICsAnswersService() ) behave like testRoute( @@ -204,7 +234,8 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP prepopAnswersService = StubPrepopAnswersService( getIncomeAnswersResult = incomePrepopAnswers.asRight, getAdjustmentsAnswersResult = adjustmentsPrepopAnswers.asRight - ) + ), + nicsAnswersService = StubNICsAnswersService() ) s"getIncomeAnswers from downstream" in { behave like testRoute( @@ -242,7 +273,8 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP incomeService = StubIncomeAnswersService(), expensesService = StubExpensesAnswersService(getTailoringJourneyAnswers = journeyAnswers), capitalAllowancesService = StubCapitalAllowancesAnswersAnswersService(), - prepopAnswersService = StubPrepopAnswersService() + prepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService = StubNICsAnswersService() ) behave like testRoute( request = buildRequestNoContent, @@ -628,7 +660,8 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP incomeService = StubIncomeAnswersService(), expensesService = StubExpensesAnswersService(), capitalAllowancesService = StubCapitalAllowancesAnswersAnswersService(getCapitalAllowancesTailoring = Some(answers).asRight), - prepopAnswersService = StubPrepopAnswersService() + prepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService = StubNICsAnswersService() ) behave like testRoute( @@ -666,7 +699,8 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP incomeService = StubIncomeAnswersService(), expensesService = StubExpensesAnswersService(), capitalAllowancesService = StubCapitalAllowancesAnswersAnswersService(getZeroEmissionCars = Some(answers).asRight), - prepopAnswersService = StubPrepopAnswersService() + prepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService = StubNICsAnswersService() ) behave like testRoute( @@ -705,7 +739,8 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP incomeService = StubIncomeAnswersService(), expensesService = StubExpensesAnswersService(), capitalAllowancesService = StubCapitalAllowancesAnswersAnswersService(getZeroEmissionGoodsVehicleCars = Some(answers).asRight), - prepopAnswersService = StubPrepopAnswersService() + prepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService = StubNICsAnswersService() ) behave like testRoute( @@ -809,6 +844,15 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP ) } + "NationalInsuranceContributions" should { + val answers = NICsAnswers(true) + val controllerWithData = mkUnderTest(nicsAnswersService = StubNICsAnswersService(getAnswersRes = Right(Some(answers)))) + + testNoContent(underTest.getNationalInsuranceContributions(currTaxYear, businessId, nino)) + testSaveAnswers(underTest.saveNationalInsuranceContributions(currTaxYear, businessId, nino), answers) + testGetReturnAnswers(controllerWithData.getNationalInsuranceContributions(currTaxYear, businessId, nino), Json.toJson(answers).toString()) + } + trait GetExpensesTest[T] { val expensesService: ExpensesAnswersService = mock[ExpensesAnswersService] val journeyAnswers: T @@ -820,7 +864,8 @@ class JourneyAnswersControllerSpec extends ControllerBehaviours with ScalaCheckP incomeService = StubIncomeAnswersService(), expensesService = expensesService, capitalAllowancesService = StubCapitalAllowancesAnswersAnswersService(), - prepopAnswersService = StubPrepopAnswersService() + prepopAnswersService = StubPrepopAnswersService(), + nicsAnswersService = StubNICsAnswersService() ) def mockExpensesService(): CallHandler3[JourneyContextWithNino, Api1786ExpensesResponseParser[T], HeaderCarrier, ApiResultT[T]] = diff --git a/test/data/api1639/SuccessResponseAPI1639Data.scala b/test/data/api1639/SuccessResponseAPI1639Data.scala new file mode 100644 index 00000000..58bc959e --- /dev/null +++ b/test/data/api1639/SuccessResponseAPI1639Data.scala @@ -0,0 +1,27 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package data.api1639 + +import models.connector.api_1639.{SuccessResponseAPI1639, SuccessResponseAPI1639Class2Nics, SuccessResponseAPI1639TaxAvoidanceInner} +import utils.BaseSpec.currTaxYear + +object SuccessResponseAPI1639Data { + val class2NicsTrue = SuccessResponseAPI1639(None, Some(SuccessResponseAPI1639Class2Nics(Some(true)))) + val full = SuccessResponseAPI1639( + Some(List(SuccessResponseAPI1639TaxAvoidanceInner("arn", currTaxYear.endYear.toString))), + Some(SuccessResponseAPI1639Class2Nics(Some(true)))) +} diff --git a/test/models/common/TaxYearSpec.scala b/test/models/common/TaxYearSpec.scala index 2d198734..463404fe 100644 --- a/test/models/common/TaxYearSpec.scala +++ b/test/models/common/TaxYearSpec.scala @@ -16,21 +16,27 @@ package models.common -import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper -import utils.BaseSpec - -class TaxYearSpec extends BaseSpec { +import org.scalatest.wordspec.AnyWordSpecLike +class TaxYearSpec extends AnyWordSpecLike { private val year = TaxYear(2024) - "getting start and end dates" must { + "getting start and end dates" should { "get April 5th and 6th" in { - TaxYear.startDate(year) shouldBe "2023-04-06" - TaxYear.endDate(year) shouldBe "2024-04-05" + assert(TaxYear.startDate(year) === "2023-04-06") + assert(TaxYear.endDate(year) === "2024-04-05") } } + "get a TYS (YY-YY) format" in { - TaxYear.asTys(year) shouldBe "23-24" + assert(TaxYear.asTys(year) === "23-24") + } + + "toYYYY_YY" should { + "get a YYYY-YY format" in { + assert(TaxYear(2024).toYYYY_YY === "2023-24") + assert(TaxYear(1999).toYYYY_YY === "1998-99") + } } } diff --git a/test/models/connector/api_1638/RequestSchemaAPI1638Spec.scala b/test/models/connector/api_1638/RequestSchemaAPI1638Spec.scala new file mode 100644 index 00000000..7dc37eea --- /dev/null +++ b/test/models/connector/api_1638/RequestSchemaAPI1638Spec.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.connector.api_1638 + +import data.api1639.SuccessResponseAPI1639Data +import models.frontend.nics.NICsAnswers +import org.scalatest.wordspec.AnyWordSpecLike +import utils.BaseSpec.currTaxYear + +class RequestSchemaAPI1638Spec extends AnyWordSpecLike { + "mkRequestBody" should { + "return class2 set to true" in { + val answers = NICsAnswers(true) + val result = RequestSchemaAPI1638.mkRequestBody(answers, None) + assert(result === Some(RequestSchemaAPI1638(None, Some(RequestSchemaAPI1638Class2Nics(Some(true)))))) + } + + "return None when answer class2 set to false and no other fields in the object exist" in { + val answers = NICsAnswers(false) + val result = RequestSchemaAPI1638.mkRequestBody(answers, maybeExistingDisclosures = None) + assert(result === None) + } + + "return an object with class 2 set to None if other fields exist" in { + val answers = NICsAnswers(false) + val result = RequestSchemaAPI1638.mkRequestBody(answers, Some(SuccessResponseAPI1639Data.full)) + assert(result === Some(RequestSchemaAPI1638(Some(List(RequestSchemaAPI1638TaxAvoidanceInner("arn", currTaxYear.toString))), None))) + } + } +} diff --git a/test/models/frontend/nics/NICsAnswersSpec.scala b/test/models/frontend/nics/NICsAnswersSpec.scala new file mode 100644 index 00000000..e5cf496a --- /dev/null +++ b/test/models/frontend/nics/NICsAnswersSpec.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.frontend.nics + +import data.api1639.SuccessResponseAPI1639Data.class2NicsTrue +import models.database.nics.NICsStorageAnswers +import org.scalatest.wordspec.AnyWordSpecLike +import org.scalatest.OptionValues._ + +class NICsAnswersSpec extends AnyWordSpecLike { + "fromApi1639" should { + "return None when there are no class 2 NICs" in { + val result = NICsAnswers.mkPriorData(None, None) + assert(result.isEmpty) + } + + "return class2 from the API" in { + val result = NICsAnswers.mkPriorData(Some(class2NicsTrue), None).value + assert(result === NICsAnswers(true)) + } + + "return class2 from the DB if does not exist in API" in { + val result = NICsAnswers.mkPriorData(None, Some(NICsStorageAnswers(Some(false)))).value + assert(result === NICsAnswers(false)) + } + + "ignore value from DB if it exist in the API" in { + val result = NICsAnswers.mkPriorData(Some(class2NicsTrue), Some(NICsStorageAnswers(Some(false)))).value + assert(result === NICsAnswers(true)) + } + + } +} diff --git a/test/services/journeyAnswers/NICsAnswersServiceImplSpec.scala b/test/services/journeyAnswers/NICsAnswersServiceImplSpec.scala new file mode 100644 index 00000000..69cb7c87 --- /dev/null +++ b/test/services/journeyAnswers/NICsAnswersServiceImplSpec.scala @@ -0,0 +1,132 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services.journeyAnswers + +import cats.implicits.catsSyntaxEitherId +import models.connector.api_1638.{RequestSchemaAPI1638, RequestSchemaAPI1638Class2Nics, RequestSchemaAPI1638TaxAvoidanceInner} +import models.connector.api_1639.{SuccessResponseAPI1639, SuccessResponseAPI1639Class2Nics, SuccessResponseAPI1639TaxAvoidanceInner} +import models.database.nics.NICsStorageAnswers +import models.frontend.nics.NICsAnswers +import org.scalatest.EitherValues._ +import org.scalatest.wordspec.AnyWordSpecLike +import play.api.libs.json.{JsObject, Json} +import stubs.connectors.StubSelfEmploymentConnector +import stubs.repositories.StubJourneyAnswersRepository +import utils.BaseSpec.{currTaxYearEnd, hc, journeyCtxWithNino} +import utils.EitherTTestOps.convertScalaFuture + +import scala.concurrent.ExecutionContext.Implicits.global + +class NICsAnswersServiceImplSpec extends AnyWordSpecLike { + + "save answers" should { + "create a new answers if nothing already exist" in new StubbedService { + val answers = NICsAnswers(true) + + val result = service.saveAnswers(journeyCtxWithNino, answers).value.futureValue + + assert(result.isRight) + assert(connector.upsertDisclosuresSubmissionData === Some(RequestSchemaAPI1638(None, Some(RequestSchemaAPI1638Class2Nics(Some(true)))))) + assert(repository.lastUpsertedAnswer === Some(JsObject.empty)) + } + + val disclosuresWithOtherFields = + SuccessResponseAPI1639(Some(List(SuccessResponseAPI1639TaxAvoidanceInner("srn", currTaxYearEnd.toUpperCase))), None) + + "setting class2 does not override other fields and true is not stored in DB" in new StubbedService { + override val connector = StubSelfEmploymentConnector(getDisclosuresSubmissionResult = Some(disclosuresWithOtherFields).asRight) + val answers = NICsAnswers(true) + + val result = service.saveAnswers(journeyCtxWithNino, answers).value.futureValue + + assert(result.isRight) + assert( + connector.upsertDisclosuresSubmissionData === Some( + RequestSchemaAPI1638( + Some(List(RequestSchemaAPI1638TaxAvoidanceInner("srn", currTaxYearEnd.toUpperCase()))), + Some(RequestSchemaAPI1638Class2Nics(Some(true)))))) + assert(repository.lastUpsertedAnswer === Some(JsObject.empty)) + } + + "settings class2 to None when class2 is false and there are other fields in the object" in new StubbedService { + override val connector = StubSelfEmploymentConnector(getDisclosuresSubmissionResult = Some(disclosuresWithOtherFields).asRight) + val answers = NICsAnswers(false) + + val result = service.saveAnswers(journeyCtxWithNino, answers).value.futureValue + + assert(result.isRight) + assert( + connector.upsertDisclosuresSubmissionData === Some( + RequestSchemaAPI1638( + Some(List(RequestSchemaAPI1638TaxAvoidanceInner("srn", currTaxYearEnd.toUpperCase()))), + None + ) + ) + ) + assert(repository.lastUpsertedAnswer === Some(Json.toJson(NICsStorageAnswers(Some(false))))) + } + + "call DELETE if setting to false and the object is empty" in new StubbedService { + override val connector = StubSelfEmploymentConnector(getDisclosuresSubmissionResult = + Some(SuccessResponseAPI1639(None, Some(SuccessResponseAPI1639Class2Nics(Some(true))))).asRight) + val answers = NICsAnswers(false) + + val result = service.saveAnswers(journeyCtxWithNino, answers).value.futureValue + + assert(result.isRight) + assert(connector.upsertDisclosuresSubmissionData === None) + assert(repository.lastUpsertedAnswer === Some(Json.toJson(answers))) + } + + } + + "get answers" should { + "return None if there are no answers" in new StubbedService { + val result = service.getAnswers(journeyCtxWithNino).value.futureValue.value + assert(result === None) + } + + "return API version even if database exist" in new StubbedService { + override val connector = StubSelfEmploymentConnector(getDisclosuresSubmissionResult = + Some(SuccessResponseAPI1639(None, Some(SuccessResponseAPI1639Class2Nics(Some(true))))).asRight) + override val repository = StubJourneyAnswersRepository( + getAnswers = Right(Some(Json.toJson(NICsStorageAnswers(Some(false))))) + ) + + val result = service.getAnswers(journeyCtxWithNino).value.futureValue.value + + assert(result === Some(NICsAnswers(true))) + } + + "return database version if no API data exist" in new StubbedService { + override val repository = StubJourneyAnswersRepository( + getAnswers = Right(Some(Json.toJson(NICsStorageAnswers(Some(false))))) + ) + + val result = service.getAnswers(journeyCtxWithNino).value.futureValue.value + + assert(result === Some(NICsAnswers(false))) + } + } + + trait StubbedService { + val connector = StubSelfEmploymentConnector() + val repository = StubJourneyAnswersRepository() + + def service = new NICsAnswersServiceImpl(connector, repository) + } +} diff --git a/test/stubs/connectors/StubSelfEmploymentConnector.scala b/test/stubs/connectors/StubSelfEmploymentConnector.scala index 7a5fefeb..527ccc99 100644 --- a/test/stubs/connectors/StubSelfEmploymentConnector.scala +++ b/test/stubs/connectors/StubSelfEmploymentConnector.scala @@ -16,10 +16,13 @@ package stubs.connectors +import cats.data.EitherT import cats.implicits.{catsSyntaxEitherId, catsSyntaxOptionId} import connectors.SelfEmploymentConnector import connectors.SelfEmploymentConnector._ import models.common.{IdType, JourneyContextWithNino} +import models.connector.api_1638.RequestSchemaAPI1638 +import models.connector.api_1639.SuccessResponseAPI1639 import models.connector.api_1786.{DeductionsType, SelfEmploymentDeductionsDetailTypePosNeg} import models.connector.api_1802.request.CreateAmendSEAnnualSubmissionRequestData import models.connector.api_1802.response.CreateAmendSEAnnualSubmissionResponse @@ -30,6 +33,8 @@ import models.connector.api_1895.request.AmendSEPeriodSummaryRequestData import models.connector.api_1895.response.AmendSEPeriodSummaryResponse import models.connector.api_1965.{ListSEPeriodSummariesResponse, PeriodDetails} import models.connector.{api_1171, api_1786} +import models.domain.ApiResultT +import models.error.ServiceError import stubs.connectors.StubSelfEmploymentConnector._ import uk.gov.hmrc.http.HeaderCarrier import utils.BaseSpec._ @@ -44,8 +49,12 @@ case class StubSelfEmploymentConnector( createAmendSEAnnualSubmissionResult: Future[Api1802Response] = Future.successful(api1802SuccessResponse.asRight), getAnnualSummariesResult: Future[Api1803Response] = Future.successful(api1803SuccessResponse.asRight), listSEPeriodSummariesResult: Future[Api1965Response] = Future.successful(api1965MatchedResponse.asRight), - getPeriodicSummaryDetailResult: Future[Api1786Response] = Future.successful(api1786EmptySuccessResponse.asRight)) - extends SelfEmploymentConnector { + getPeriodicSummaryDetailResult: Future[Api1786Response] = Future.successful(api1786EmptySuccessResponse.asRight), + getDisclosuresSubmissionResult: Either[ServiceError, Option[SuccessResponseAPI1639]] = Right(None), + upsertDisclosuresSubmissionResult: Either[ServiceError, Unit] = Right(()), + deleteDisclosuresSubmissionResult: Either[ServiceError, Unit] = Right(()) +) extends SelfEmploymentConnector { + var upsertDisclosuresSubmissionData: Option[RequestSchemaAPI1638] = None override def createSEPeriodSummary( data: CreateSEPeriodSummaryRequestData)(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Api1894Response] = @@ -70,6 +79,23 @@ case class StubSelfEmploymentConnector( def getAnnualSummaries(ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier, ec: ExecutionContext): Future[Api1803Response] = getAnnualSummariesResult + + def getDisclosuresSubmission( + ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier, ec: ExecutionContext): ApiResultT[Option[SuccessResponseAPI1639]] = + EitherT.fromEither[Future](getDisclosuresSubmissionResult) + + def upsertDisclosuresSubmission(ctx: JourneyContextWithNino, data: RequestSchemaAPI1638)(implicit + hc: HeaderCarrier, + ec: ExecutionContext): ApiResultT[Unit] = { + upsertDisclosuresSubmissionData = Some(data) + EitherT.fromEither[Future](upsertDisclosuresSubmissionResult) + } + + def deleteDisclosuresSubmission(ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier, ec: ExecutionContext): ApiResultT[Unit] = { + upsertDisclosuresSubmissionData = None + EitherT.fromEither[Future](deleteDisclosuresSubmissionResult) + } + } object StubSelfEmploymentConnector { diff --git a/test/stubs/repositories/StubJourneyAnswersRepository.scala b/test/stubs/repositories/StubJourneyAnswersRepository.scala index 07473d37..d9f7da2c 100644 --- a/test/stubs/repositories/StubJourneyAnswersRepository.scala +++ b/test/stubs/repositories/StubJourneyAnswersRepository.scala @@ -23,22 +23,29 @@ import models.database.JourneyAnswers import models.domain.{ApiResultT, Business} import models.error.ServiceError import models.frontend.TaskList -import play.api.libs.json.JsValue +import models.jsonAs +import org.scalatest.EitherValues._ +import play.api.libs.json.{JsObject, JsValue, Reads} import repositories.JourneyAnswersRepository import scala.concurrent.{ExecutionContext, Future} +import scala.reflect.ClassTag case class StubJourneyAnswersRepository( getAnswer: Option[JourneyAnswers] = None, + getAnswers: Either[ServiceError, Option[JsValue]] = Right(None), upsertDateField: Either[ServiceError, Unit] = Right(()), upsertStatusField: Either[ServiceError, Unit] = Right(()), getAllResult: Either[ServiceError, TaskList] = Right(TaskList.empty), deleteOneOrMoreJourneys: Either[ServiceError, Unit] = Right(()) ) extends JourneyAnswersRepository { - implicit val ec: ExecutionContext = ExecutionContext.global + implicit val ec: ExecutionContext = ExecutionContext.global + var lastUpsertedAnswer: Option[JsValue] = None - def upsertAnswers(ctx: JourneyContext, newData: JsValue): ApiResultT[Unit] = + def upsertAnswers(ctx: JourneyContext, newData: JsValue): ApiResultT[Unit] = { + lastUpsertedAnswer = Some(newData) EitherT.fromEither[Future](upsertDateField) + } def setStatus(ctx: JourneyContext, status: JourneyStatus): ApiResultT[Unit] = EitherT.fromEither[Future](upsertStatusField) @@ -53,4 +60,7 @@ case class StubJourneyAnswersRepository( def getAll(taxYear: TaxYear, mtditid: Mtditid, businesses: List[Business]): ApiResultT[TaskList] = EitherT.fromEither[Future](getAllResult) + + def getAnswers[A: Reads](ctx: JourneyContext)(implicit ct: ClassTag[A]): ApiResultT[Option[A]] = + EitherT.fromEither(getAnswers.map(_.map(data => jsonAs[A](data.as[JsObject]).value))) } diff --git a/test/stubs/services/StubNICsAnswersService.scala b/test/stubs/services/StubNICsAnswersService.scala new file mode 100644 index 00000000..c58f4324 --- /dev/null +++ b/test/stubs/services/StubNICsAnswersService.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package stubs.services + +import cats.data.EitherT +import models.common.JourneyContextWithNino +import models.domain.ApiResultT +import models.error.ServiceError +import models.frontend.nics.NICsAnswers +import services.journeyAnswers.NICsAnswersService +import uk.gov.hmrc.http.HeaderCarrier + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class StubNICsAnswersService(getAnswersRes: Either[ServiceError, Option[NICsAnswers]] = Right(None)) extends NICsAnswersService { + + def saveAnswers(ctx: JourneyContextWithNino, answers: NICsAnswers)(implicit hc: HeaderCarrier): ApiResultT[Unit] = + EitherT.rightT[Future, ServiceError](()) + + def getAnswers(ctx: JourneyContextWithNino)(implicit hc: HeaderCarrier): ApiResultT[Option[NICsAnswers]] = + EitherT.fromEither[Future](getAnswersRes) +}