diff --git a/app/uk/gov/hmrc/epayeapi/connectors/EpayeConnector.scala b/app/uk/gov/hmrc/epayeapi/connectors/EpayeConnector.scala index f9825c6..bb5357e 100644 --- a/app/uk/gov/hmrc/epayeapi/connectors/EpayeConnector.scala +++ b/app/uk/gov/hmrc/epayeapi/connectors/EpayeConnector.scala @@ -20,7 +20,7 @@ import javax.inject.{Inject, Singleton} import uk.gov.hmrc.domain.EmpRef import uk.gov.hmrc.epayeapi.models.Formats._ -import uk.gov.hmrc.epayeapi.models.in.{AnnualSummaryResponse, ApiResponse, EpayeTotalsResponse, TaxYear} +import uk.gov.hmrc.epayeapi.models.in.{EpayeAnnualStatement, ApiResponse, EpayeTotalsResponse, TaxYear} import uk.gov.hmrc.play.http.HeaderCarrier import uk.gov.hmrc.play.http.ws.WSHttp @@ -45,19 +45,14 @@ case class EpayeConnector @Inject() ( get[EpayeTotalsResponse](url, headers) } - def getAnnualSummary(empRef: EmpRef, headers: HeaderCarrier, taxYear: Option[String]): Future[ApiResponse[AnnualSummaryResponse]] = { + def getAnnualStatement(empRef: EmpRef, taxYear: TaxYear, headers: HeaderCarrier): Future[ApiResponse[EpayeAnnualStatement]] = { val url = s"${config.baseUrl}" + s"/epaye" + s"/${empRef.encodedValue}" + - s"/api/v1/annual-statement" + - taxYear - .flatMap(TaxYear.extractTaxYear) - .map(TaxYear.asString) - .map(q => s"/$q") - .getOrElse("") - - get[AnnualSummaryResponse](url, headers) + s"/api/v1/annual-statement/${taxYear.asString}" + + get[EpayeAnnualStatement](url, headers) } } diff --git a/app/uk/gov/hmrc/epayeapi/controllers/GetAnnualStatementController.scala b/app/uk/gov/hmrc/epayeapi/controllers/GetAnnualStatementController.scala new file mode 100644 index 0000000..23921b6 --- /dev/null +++ b/app/uk/gov/hmrc/epayeapi/controllers/GetAnnualStatementController.scala @@ -0,0 +1,60 @@ +/* + * Copyright 2017 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 uk.gov.hmrc.epayeapi.controllers + +import javax.inject.{Inject, Singleton} + +import akka.stream.Materializer +import play.api.Logger +import play.api.libs.json.Json +import play.api.mvc.{Action, EssentialAction} +import uk.gov.hmrc.auth.core.AuthConnector +import uk.gov.hmrc.domain.EmpRef +import uk.gov.hmrc.epayeapi.connectors.EpayeConnector +import uk.gov.hmrc.epayeapi.models.Formats._ +import uk.gov.hmrc.epayeapi.models.in._ +import uk.gov.hmrc.epayeapi.models.out.ApiError.EmpRefNotFound +import uk.gov.hmrc.epayeapi.models.out.{AnnualStatementJson, ApiError} + +import scala.concurrent.ExecutionContext + +@Singleton +case class GetAnnualStatementController @Inject()( + authConnector: AuthConnector, + epayeConnector: EpayeConnector, + implicit val ec: ExecutionContext, + implicit val mat: Materializer +) + extends ApiController { + + def getAnnualStatement(empRef: EmpRef, taxYear: TaxYear): EssentialAction = EmpRefAction(empRef) { + Action.async { request => + epayeConnector.getAnnualStatement(empRef, taxYear, hc(request)).map { + case ApiSuccess(epayeAnnualStatement) => + Ok(Json.toJson(AnnualStatementJson(empRef, taxYear, epayeAnnualStatement))) + case ApiJsonError(err) => + Logger.error(s"Upstream returned invalid json: $err") + InternalServerError(Json.toJson(ApiError.InternalServerError)) + case ApiNotFound() => + NotFound(Json.toJson(EmpRefNotFound)) + case error: ApiResponse[_] => + Logger.error(s"Error while fetching totals: $error") + InternalServerError(Json.toJson(ApiError.InternalServerError)) + } + } + } +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/epayeapi/models/in/AnnualSummaryResponse.scala b/app/uk/gov/hmrc/epayeapi/models/in/EpayeAnnualStatement.scala similarity index 83% rename from app/uk/gov/hmrc/epayeapi/models/in/AnnualSummaryResponse.scala rename to app/uk/gov/hmrc/epayeapi/models/in/EpayeAnnualStatement.scala index 3532222..160c372 100644 --- a/app/uk/gov/hmrc/epayeapi/models/in/AnnualSummaryResponse.scala +++ b/app/uk/gov/hmrc/epayeapi/models/in/EpayeAnnualStatement.scala @@ -28,7 +28,6 @@ case class Cleared( credit: BigDecimal = 0 ) - case class TaxMonth(month: Int) { def firstDay(year: TaxYear): LocalDate = year.firstDay.plusMonths(month - 1) @@ -45,7 +44,8 @@ case class LineItem( balance: DebitAndCredit, dueDate: LocalDate, isSpecified: Boolean = false, - codeText: Option[String] = None + codeText: Option[String] = None, + itemType: Option[String] = None ) case class AnnualTotal( @@ -54,6 +54,6 @@ case class AnnualTotal( balance: DebitAndCredit ) -case class AnnualSummary(lineItems: Seq[LineItem], totals: AnnualTotal) +case class AnnualStatementTable(lineItems: Seq[LineItem], totals: AnnualTotal) -case class AnnualSummaryResponse(rti: AnnualSummary, nonRti: AnnualSummary) +case class EpayeAnnualStatement(rti: AnnualStatementTable, nonRti: AnnualStatementTable, unallocated: Option[BigDecimal]) \ No newline at end of file diff --git a/app/uk/gov/hmrc/epayeapi/models/in/Formats.scala b/app/uk/gov/hmrc/epayeapi/models/in/Formats.scala index 8967722..3f597ba 100644 --- a/app/uk/gov/hmrc/epayeapi/models/in/Formats.scala +++ b/app/uk/gov/hmrc/epayeapi/models/in/Formats.scala @@ -26,8 +26,8 @@ trait Formats { implicit lazy val annualTotalFormat: Format[AnnualTotal] = format[AnnualTotal] implicit lazy val taxMonthFormat: Format[TaxMonth] = format[TaxMonth] implicit lazy val lineItemFormat: Format[LineItem] = format[LineItem] - implicit lazy val annualSummaryFormat: Format[AnnualSummary] = format[AnnualSummary] - implicit lazy val annualSummaryResponseFormat: Format[AnnualSummaryResponse] = format[AnnualSummaryResponse] + implicit lazy val annualSummaryFormat: Format[AnnualStatementTable] = format[AnnualStatementTable] + implicit lazy val annualSummaryResponseFormat: Format[EpayeAnnualStatement] = format[EpayeAnnualStatement] implicit lazy val epayeTotals: Format[EpayeTotals] = format[EpayeTotals] implicit lazy val epayeTotalsItems: Format[EpayeTotalsItem] = format[EpayeTotalsItem] diff --git a/app/uk/gov/hmrc/epayeapi/models/in/TaxYear.scala b/app/uk/gov/hmrc/epayeapi/models/in/TaxYear.scala index 6c25fc7..6699b4d 100644 --- a/app/uk/gov/hmrc/epayeapi/models/in/TaxYear.scala +++ b/app/uk/gov/hmrc/epayeapi/models/in/TaxYear.scala @@ -26,15 +26,19 @@ case class TaxYear(yearFrom: Int) { val asString: String = s"$yearFrom-${yearTo % 100}" val firstDay: LocalDate = TaxYearResolver.startOfTaxYear(yearFrom) val lastDay: LocalDate = firstDay.plusYears(1).minusDays(1) + def next: TaxYear = TaxYear(yearTo) + def previous = TaxYear(yearFrom - 1) } object TaxYear { - private lazy val pattern = """20(\d\d)-(\d\d)""".r - def asString(taxYear: TaxYear): String = s"${taxYear.yearFrom}-${taxYear.yearTo % 100}" +} - def extractTaxYear(taxYear: String): Option[TaxYear] = { +object ExtractTaxYear { + private lazy val pattern = """20(\d\d)-(\d\d)""".r + + def unapply(taxYear: String): Option[TaxYear] = { taxYear match { case pattern(fromYear, toYear) => Try(toYear.toInt - fromYear.toInt) match { @@ -44,4 +48,4 @@ object TaxYear { case _ => None } } -} +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/epayeapi/models/out/AnnualStatementJson.scala b/app/uk/gov/hmrc/epayeapi/models/out/AnnualStatementJson.scala new file mode 100644 index 0000000..c9ba97f --- /dev/null +++ b/app/uk/gov/hmrc/epayeapi/models/out/AnnualStatementJson.scala @@ -0,0 +1,160 @@ +/* + * Copyright 2017 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 uk.gov.hmrc.epayeapi.models.out + +import org.joda.time.LocalDate +import uk.gov.hmrc.domain.EmpRef +import uk.gov.hmrc.epayeapi.models.in.{EpayeAnnualStatement, LineItem, TaxYear} + +case class TaxYearJson(year: String, firstDay: LocalDate, lastDay: LocalDate) + +case class PeriodJson(firstDay: LocalDate, lastDay: LocalDate) + +case class NonRtiChargesJson( + code: String, + amount: BigDecimal, + clearedByCredits: BigDecimal, + clearedByPayments: BigDecimal, + balance: BigDecimal, + dueDate: LocalDate +) + +object NonRtiChargesJson { + def from(lineItem: LineItem, taxYear: TaxYear): Option[NonRtiChargesJson] = { + for { + code <- lineItem.codeText + } yield NonRtiChargesJson( + code = code, + amount = lineItem.charges.debit, + clearedByCredits = lineItem.cleared.credit, + clearedByPayments = lineItem.cleared.payment, + balance = lineItem.balance.debit, + dueDate = lineItem.dueDate) + } +} + +case class PaymentsAndCreditsJson( + payments: BigDecimal, + credits: BigDecimal +) + +case class EarlierYearUpdateJson( + amount: BigDecimal, + clearedByCredits: BigDecimal, + clearedByPayments: BigDecimal, + balance: BigDecimal, + dueDate: LocalDate +) + +object EarlierYearUpdateJson { + def extractFrom(lineItems: Seq[LineItem]): Option[EarlierYearUpdateJson] = { + lineItems + .find(_.itemType.contains("eyu")) + .map { lineItem => + EarlierYearUpdateJson( + lineItem.charges.debit, + lineItem.cleared.credit, + lineItem.cleared.payment, + lineItem.balance.debit, + lineItem.dueDate + ) + } + } +} + +case class EmbeddedRtiChargesJson( + earlierYearUpdate: Option[EarlierYearUpdateJson], + rtiCharges: Seq[MonthlyChargesJson] +) + +case class MonthlyChargesJson( + taxMonth: TaxMonthJson, + amount: BigDecimal, + clearedByCredits: BigDecimal, + clearedByPayments: BigDecimal, + balance: BigDecimal, + dueDate: LocalDate, + isSpecified: Boolean, + _links: SelfLink +) + +object MonthlyChargesJson { + + def from(lineItem: LineItem, empRef: EmpRef, taxYear: TaxYear): Option[MonthlyChargesJson] = { + for { + taxMonth <- lineItem.taxMonth + } yield MonthlyChargesJson( + taxMonth = TaxMonthJson(taxMonth.month, taxMonth.firstDay(taxYear), taxMonth.lastDay(taxYear)), + amount = lineItem.charges.debit, + clearedByCredits = lineItem.cleared.credit, + clearedByPayments = lineItem.cleared.payment, + balance = lineItem.balance.debit, + dueDate = lineItem.dueDate, + isSpecified = lineItem.isSpecified, + _links = SelfLink(Link(s"${AnnualStatementJson.baseUrlFor(empRef)}/statements/${taxYear.asString}/${taxMonth.month}")) + ) + } +} + +case class TaxMonthJson( + number: Int, + firstDay: LocalDate, + lastDay: LocalDate +) +case class SelfLink( + self: Link +) + +case class AnnualStatementLinksJson( + empRefs: Link, + statements: Link, + self: Link, + next: Link, + previous: Link +) + +case class AnnualStatementJson( + taxYear: TaxYearJson, + nonRtiCharges: Seq[NonRtiChargesJson], + _embedded: EmbeddedRtiChargesJson, + _links: AnnualStatementLinksJson +) + +object AnnualStatementJson { + val baseUrl = "/organisations/paye" + + def baseUrlFor(empRef: EmpRef): String = + s"$baseUrl/${empRef.taxOfficeNumber}/${empRef.taxOfficeReference}" + + def apply(empRef: EmpRef, taxYear: TaxYear, epayeAnnualStatement: EpayeAnnualStatement): AnnualStatementJson = + AnnualStatementJson( + taxYear = TaxYearJson(taxYear.asString, taxYear.firstDay, taxYear.lastDay), + _embedded = EmbeddedRtiChargesJson( + EarlierYearUpdateJson.extractFrom(epayeAnnualStatement.rti.lineItems), + epayeAnnualStatement.rti.lineItems.flatMap(MonthlyChargesJson.from(_, empRef, taxYear)) + ), + nonRtiCharges = epayeAnnualStatement.nonRti.lineItems.flatMap(NonRtiChargesJson.from(_, taxYear)), + _links = AnnualStatementLinksJson( + empRefs = Link(baseUrl), + statements = Link(s"${baseUrlFor(empRef)}/statements"), + self = Link(s"${baseUrlFor(empRef)}/statements/${taxYear.asString}"), + next = Link(s"${baseUrlFor(empRef)}/statements/${taxYear.next.asString}"), + previous = Link(s"${baseUrlFor(empRef)}/statements/${taxYear.previous.asString}") + ) + ) + +} \ No newline at end of file diff --git a/app/uk/gov/hmrc/epayeapi/models/out/Formats.scala b/app/uk/gov/hmrc/epayeapi/models/out/Formats.scala index 47148fd..2cd1a27 100644 --- a/app/uk/gov/hmrc/epayeapi/models/out/Formats.scala +++ b/app/uk/gov/hmrc/epayeapi/models/out/Formats.scala @@ -26,6 +26,20 @@ trait Formats { } implicit lazy val linkFormat: Format[Link] = format[Link] + implicit lazy val taxYearJsonFormat: Format[TaxYearJson] = format[TaxYearJson] + implicit lazy val periodJsonFormat: Format[PeriodJson] = format[PeriodJson] + implicit lazy val earlierYearUpdateJsonFormat: Format[EarlierYearUpdateJson] = format[EarlierYearUpdateJson] + implicit lazy val nonRtiChargesJsonFormat: Format[NonRtiChargesJson] = format[NonRtiChargesJson] + implicit lazy val paymentsAndCreditsJsonFormat: Format[PaymentsAndCreditsJson] = format[PaymentsAndCreditsJson] + implicit lazy val embeddedRtiChargesJsonFormat: Format[EmbeddedRtiChargesJson] = format[EmbeddedRtiChargesJson] + implicit lazy val rtiChargesJsonFormat: Format[MonthlyChargesJson] = format[MonthlyChargesJson] + implicit lazy val taxMonthJsonFormat: Format[TaxMonthJson] = format[TaxMonthJson] + implicit lazy val annualStatementLinksJsonFormat: Format[AnnualStatementLinksJson] = format[AnnualStatementLinksJson] + implicit lazy val annualStatementJsonFormat: Format[AnnualStatementJson] = format[AnnualStatementJson] + + + implicit lazy val selfLinksFormat: Format[SelfLink] = format[SelfLink] + implicit lazy val empRefsLinksFormat: Format[EmpRefsLinks] = format[EmpRefsLinks] implicit lazy val empRefLinksFormat: Format[EmpRefLinks] = format[EmpRefLinks] implicit lazy val empRefItemFormat: Format[EmpRefItem] = format[EmpRefItem] diff --git a/app/uk/gov/hmrc/epayeapi/router/ApiRouter.scala b/app/uk/gov/hmrc/epayeapi/router/ApiRouter.scala index a10da58..090a5ec 100644 --- a/app/uk/gov/hmrc/epayeapi/router/ApiRouter.scala +++ b/app/uk/gov/hmrc/epayeapi/router/ApiRouter.scala @@ -22,17 +22,21 @@ import play.api.routing.Router.Routes import play.api.routing.{Router, SimpleRouter} import play.api.routing.sird._ import uk.gov.hmrc.domain.EmpRef -import uk.gov.hmrc.epayeapi.controllers.GetSummaryController +import uk.gov.hmrc.epayeapi.controllers.{GetAnnualStatementController, GetSummaryController} +import uk.gov.hmrc.epayeapi.models.in.{ExtractTaxYear, TaxYear} @Singleton case class ApiRouter @Inject() ( prodRoutes: prod.Routes, - getTotalsController: GetSummaryController + getTotalsController: GetSummaryController, + getAnnualStatementController: GetAnnualStatementController ) extends SimpleRouter { val appRoutes = Router.from { case GET(p"/$taxOfficeNumber/$taxOfficeReference/") => getTotalsController.getSummary(EmpRef(taxOfficeNumber, taxOfficeReference)) + case GET(p"/$taxOfficeNumber/$taxOfficeReference/statements/${ExtractTaxYear(taxYear)}") => + getAnnualStatementController.getAnnualStatement(EmpRef(taxOfficeNumber, taxOfficeReference), taxYear) } val routes: Routes = appRoutes.routes.orElse(prodRoutes.routes) diff --git a/resources/public/api/conf/1.0/application.raml b/resources/public/api/conf/1.0/application.raml index 583e029..c7c6cc8 100644 --- a/resources/public/api/conf/1.0/application.raml +++ b/resources/public/api/conf/1.0/application.raml @@ -95,4 +95,43 @@ types: "code" : "INVALID_EMPREF", "message" : "Provided EmpRef is not associated with your account" } + /{taxOfficeNumber}/{taxOfficeReference}/statements/{taxYear}: + uriParameters: + taxOfficeNumber: + description: A unique identifier made up of tax office number + type: string + example: "001" + taxOfficeReference: + description: A unique identifier made up of the tax office reference + type: string + example: "A000001" + taxYear: + description: The tax year... + type: string + example: "2017-18" + get: + is: [ headers.acceptHeader ] + displayName: Get the Annual Statement for a given tax year + description: This resource returns the Annual Statement including a summary and breakdown of both RTI and non-RTI charges + (annotations.scope): "read:epaye" + securedBy: [ sec.oauth_2_0: { scopes: [ "read:epaye" ] } ] + responses: + 200: + body: + application/json: + type: !include schemas/AnnualStatement.get.schema.json + example: !include examples/AnnualStatement.get.json + 403: + body: + application/json: + type: !include schemas/ErrorCodes.schema.json + examples: + notOpenStatus: + description: You don't currently have an ePAYE enrolment on this account. + value: | + { + "code" : "INVALID_EMPREF", + "message" : "Provided EmpRef is not associated with your account" + } + diff --git a/resources/public/api/conf/1.0/examples/AnnualStatement.get.json b/resources/public/api/conf/1.0/examples/AnnualStatement.get.json new file mode 100644 index 0000000..d0ee7e2 --- /dev/null +++ b/resources/public/api/conf/1.0/examples/AnnualStatement.get.json @@ -0,0 +1,63 @@ +{ + "taxYear": { + "year": "2016-17", + "firstDay": "2016-04-06", + "lastDay": "2017-04-05" + }, + "nonRtiCharges": [ + { + "code": "NON_RTI_CIS_FIXED_PENALTY", + "amount": 5000, + "clearedByCredits": 500, + "clearedByPayments": 2000, + "balance": 2500, + "dueDate": "2016-07-22" + } + ], + "_embedded": { + "earlierYearUpdate": { + "amount": 2000, + "clearedByCredits": 300, + "clearedByPayments": 200, + "balance": 1500, + "dueDate": "2016-05-22" + }, + "rtiCharges": [ + { + "taxMonth": { + "number": 4, + "firstDay": "2016-07-06", + "lastDay": "2016-08-05" + }, + "amount": 1200, + "clearedByCredits": 100, + "clearedByPayments": 200, + "balance": 900, + "dueDate": "2016-08-22", + "isSpecified": false, + "_links": { + "self": { + "href": "/organisations/paye/840/GZ00064/statements/2016-17/4" + } + } + } + ] + }, + "_links": { + "empRefs": { + "href": "/organisations/paye/" + }, + "statements": { + "href": "/organisations/paye/840/GZ00064/statements" + }, + "self": { + "href": "/organisations/paye/840/GZ00064/statements/2016-17" + }, + "next": { + "href": "/organisations/paye/840/GZ00064/statements/2017-18" + }, + "previous": { + "href": "/organisations/paye/840/GZ00064/statements/2015-16" + } + } +} \ No newline at end of file diff --git a/resources/public/api/conf/1.0/schemas/AnnualStatement.get.schema.json b/resources/public/api/conf/1.0/schemas/AnnualStatement.get.schema.json new file mode 100644 index 0000000..c7ac3a5 --- /dev/null +++ b/resources/public/api/conf/1.0/schemas/AnnualStatement.get.schema.json @@ -0,0 +1,188 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "description": "Annual Statement for the requested year", + "properties": { + "taxYear": { + "$ref": "Definitions.schema.json#/definitions/taxYear" + }, + "nonRtiCharges": { + "$ref": "#/definitions/nonRtiCharges" + }, + "_embedded": { + "$ref": "#/definitions/annualStatementEmbedded" + }, + "_links": { + "$ref": "#/definitions/annualStatementLinks" + } + }, + "required": [ + "taxYear", + "nonRtiCharges", + "_embedded", + "_links" + ], + "definitions": { + "nonRtiCharges": { + "type": "array", + "description": "Non-RTI charges", + "items": { + "allOf": [ + { + "$ref": "#/definitions/chargeSummary" + }, + { + "properties": { + "code": { + "type": "string", + "description": "Code name for the non-RTI charge" + }, + "dueDate": { + "description": "Due date for the non-RTI charge", + "$ref": "Definitions.schema.json#/definitions/date" + } + }, + "required": [ + "code", + "dueDate" + ] + } + ] + } + }, + "annualStatementEmbedded": { + "type": "object", + "description": "Embedded RTI charges", + "properties": { + "earlierYearUpdate": { + "type": "object", + "description": "Earlier year update on RTI charges (optional)", + "allOf": [ + { + "$ref": "#/definitions/chargeSummary" + }, + { + "type": "object", + "properties": { + "dueDate": { + "description": "Due date for the RTI charge", + "$ref": "Definitions.schema.json#/definitions/date" + } + }, + "required": [ + "dueDate" + ] + } + ] + }, + "rtiCharges": { + "type": "array", + "description": "RTI charges", + "items": { + "allOf": [ + { + "$ref": "#/definitions/chargeSummary" + }, + { + "type": "object", + "description": "RTI charge additional details", + "properties": { + "taxMonth": { + "$ref": "Definitions.schema.json#/definitions/taxMonth" + }, + "dueDate": { + "description": "Due date for the RTI charge", + "$ref": "Definitions.schema.json#/definitions/date" + }, + "isSpecified": { + "type": "boolean", + "description": "Set to true if the charge is estimated and false otherwise" + }, + "_links": { + "type": "object", + "properties": { + "self": { + "$ref": "Definitions.schema.json#/definitions/link" + } + }, + "required": [ + "self" + ] + } + }, + "required": [ + "taxMonth", + "dueDate", + "isSpecified", + "_links" + ] + } + ] + } + } + }, + "required": [ + "rtiCharges" + ] + }, + "annualStatementLinks": { + "type": "object", + "properties": { + "empRefs": { + "description": "Link to the list of available empRefs", + "$ref": "Definitions.schema.json#/definitions/link" + }, + "statements": { + "description": "Link to the account summary for the given empRef", + "$ref": "Definitions.schema.json#/definitions/link" + }, + "self": { + "$ref": "Definitions.schema.json#/definitions/link" + }, + "next": { + "description": "Link to the Annual Statement for the next tax year", + "$ref": "Definitions.schema.json#/definitions/link" + }, + "previous": { + "description": "Link to the Annual Statement for the previous tax year", + "$ref": "Definitions.schema.json#/definitions/link" + } + }, + "required": [ + "empRefs", + "statements", + "self", + "next", + "previous" + ] + }, + "chargeSummary": { + "type": "object", + "description": "Charge summary", + "properties": { + "amount": { + "type": "number", + "description": "Charge amount" + }, + "clearedByCredits": { + "type": "number", + "description": "The amount cleared by credits" + }, + "clearedByPayments": { + "type": "number", + "description": "The amount cleared by payments" + }, + "balance": { + "type": "number", + "description": "Charge balance" + } + }, + "required": [ + "amount", + "clearedByCredits", + "clearedByPayments", + "balance" + ] + } + } +} diff --git a/resources/public/api/conf/1.0/schemas/Definitions.schema.json b/resources/public/api/conf/1.0/schemas/Definitions.schema.json index 0a6ed1d..b1f14a0 100644 --- a/resources/public/api/conf/1.0/schemas/Definitions.schema.json +++ b/resources/public/api/conf/1.0/schemas/Definitions.schema.json @@ -3,7 +3,7 @@ "date": { "description": "A date representation with format YYYY-MM-DD", "type": "string", - "pattern": "^(((19|20)([2468][048]|[13579][26]|0[48])|2000)[-]02[-]29|((19|20)[0-9]{2}[-](0[469]|11)[-](0[1-9]|[11][0-9]|30)|(19|20)[0-9]{2}[-](0[13578]|1[02])[-](0[1-9]|[12][0-9]|3[01])|(19|20)[0-9]{2}[-]02[-](0[1-9]|1[0-9]|2[0-8])))$", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "example": "2017-05-05" }, "taxYear": { @@ -14,7 +14,7 @@ "year": { "type": "string", "description": "The tax year", - "pattern": "^\\d{4}-\\d{2}" + "pattern": "^\\d{4}-\\d{2}$" }, "firstDay": { "description": "The first day of the tax year", @@ -24,7 +24,12 @@ "description": "The last day of the tax year", "$ref": "#/definitions/date" } - } + }, + "required": [ + "year", + "firstDay", + "lastDay" + ] }, "taxMonth": { "type": "object", @@ -45,7 +50,12 @@ "description": "The last day of the tax month", "$ref": "#/definitions/date" } - } + }, + "required": [ + "number", + "firstDay", + "lastDay" + ] }, "link": { "type": "object", @@ -149,50 +159,11 @@ "NON_RTI_TAX_INTEREST" ] }, - - "dueDate": { - "$ref": "#/definitions/date", - "description": "The date this item is due" - }, "is_overdue": { "type": "boolean", "description": "Whether this item is overdue or not", "default": false }, - - "outstandingCharges": { - "type": "object", - "description": "The outstanding charges on account.", - "properties": { - "amount": { - "type": "number", - "description": "The outstanding charges on account." - }, - "breakdown": { - "type": "object", - "description": "The breakdown of outstanding charges.", - "properties": { - "rti": { - "type": "number", - "description": "The outstanding RTI charges on account." - }, - "nonRti": { - "type": "number", - "description": "The outstanding Non-RTI charges on account." - } - }, - "required": [ - "rti", - "nonRti" - ] - } - }, - "required": [ - "amount", - "breakdown" - ] - }, - "epayeLinks": { "type": "object", "description": "An object of links to explore further data.", @@ -201,7 +172,10 @@ "description": "Link to the entry endpoint.", "$ref": "#/definitions/link" } - } + }, + "required": [ + "self" + ] }, "empRefLinks": { "type": "object", @@ -211,21 +185,10 @@ "description": "Link to account summary endpoint.", "$ref": "#/definitions/link" } - } - }, - "summaryLinks": { - "type": "object", - "description": "An object of links to explore further data.", - "properties": { - "self": { - "description": "Link to account summary endpoint.", - "$ref": "#/definitions/link" - }, - "empRefs": { - "description": "Link to the entry endpoint endpoint.", - "$ref": "#/definitions/link" - } - } + }, + "required": [ + "summary" + ] } } -} +} \ No newline at end of file diff --git a/resources/public/api/conf/1.0/schemas/Summary.get.schema.json b/resources/public/api/conf/1.0/schemas/Summary.get.schema.json index ac33607..0e7d295 100644 --- a/resources/public/api/conf/1.0/schemas/Summary.get.schema.json +++ b/resources/public/api/conf/1.0/schemas/Summary.get.schema.json @@ -1,16 +1,67 @@ { + "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", - "description": "An object containing the account summary.", + "description": "Account summary for the given empRef", "properties": { - "_links": { - "$ref": "Definitions.schema.json#/definitions/summaryLinks" - }, "outstandingCharges": { - "$ref": "Definitions.schema.json#/definitions/outstandingCharges" + "$ref": "#/definitions/outstandingCharges" + }, + "_links": { + "$ref": "#/definitions/summaryLinks" } }, "required": [ "outstandingCharges", "_links" - ] + ], + "definitions": { + "outstandingCharges": { + "type": "object", + "description": "Outstanding charges on the account", + "properties": { + "amount": { + "type": "number", + "description": "Outstanding amount" + }, + "breakdown": { + "type": "object", + "description": "Breakdown of outstanding charges", + "properties": { + "rti": { + "type": "number", + "description": "Outstanding RTI charges on account." + }, + "nonRti": { + "type": "number", + "description": "Outstanding Non-RTI charges on account." + } + }, + "required": [ + "rti", + "nonRti" + ] + } + }, + "required": [ + "amount", + "breakdown" + ] + }, + "summaryLinks": { + "type": "object", + "properties": { + "self": { + "$ref": "Definitions.schema.json#/definitions/link" + }, + "empRefs": { + "description": "Link to the list of available empRefs", + "$ref": "Definitions.schema.json#/definitions/link" + } + }, + "required": [ + "self", + "empRefs" + ] + } + } } diff --git a/test/contract/EmpRefGenerator.scala b/test/common/EmpRefGenerator.scala similarity index 99% rename from test/contract/EmpRefGenerator.scala rename to test/common/EmpRefGenerator.scala index 2dbb4ad..99ea777 100644 --- a/test/contract/EmpRefGenerator.scala +++ b/test/common/EmpRefGenerator.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package contract +package common import uk.gov.hmrc.domain.EmpRef diff --git a/test/common/Fixtures.scala b/test/common/Fixtures.scala new file mode 100644 index 0000000..5bfe97f --- /dev/null +++ b/test/common/Fixtures.scala @@ -0,0 +1,268 @@ +/* + * Copyright 2017 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 common + +import play.api.libs.json.Json +import uk.gov.hmrc.domain.EmpRef + +object Fixtures { + + val epayeAnnualStatement: String = + """ + |{ + | "rti": { + | "lineItems": [ + | { + | "taxYear": { + | "yearFrom": 2017 + | }, + | "taxMonth": { + | "month": 7 + | }, + | "charges": { + | "debit": 1200, + | "credit": 0 + | }, + | "cleared": { + | "cleared": 0, + | "payment": 0, + | "credit": 0 + | }, + | "balance": { + | "debit": 1200, + | "credit": 0 + | }, + | "dueDate": "2017-11-22", + | "isSpecified": false, + | "itemType": "month" + | }, + | { + | "taxYear": { + | "yearFrom": 2017 + | }, + | "charges": { + | "debit": 700, + | "credit": 0 + | }, + | "cleared": { + | "cleared": 0, + | "payment": 300, + | "credit": 200 + | }, + | "balance": { + | "debit": 200, + | "credit": 0 + | }, + | "dueDate": "2017-04-22", + | "isSpecified": false, + | "itemType": "eyu" + | }, + | { + | "taxYear": { + | "yearFrom": 2017 + | }, + | "taxMonth": { + | "month": 3 + | }, + | "charges": { + | "debit": 700, + | "credit": 0 + | }, + | "cleared": { + | "cleared": 0, + | "payment": 300, + | "credit": 200 + | }, + | "balance": { + | "debit": 200, + | "credit": 0 + | }, + | "dueDate": "2017-07-22", + | "isSpecified": true + | } + | ], + | "totals": { + | "charges": { + | "debit": 1900, + | "credit": 0 + | }, + | "cleared": { + | "cleared": 0, + | "payment": 300, + | "credit": 200 + | }, + | "balance": { + | "debit": 1400, + | "credit": 0 + | } + | } + | }, + | "nonRti": { + | "lineItems": [ + | { + | "taxYear": { + | "yearFrom": 2017 + | }, + | "charges": { + | "debit": 300, + | "credit": 0 + | }, + | "cleared": { + | "cleared": 100, + | "payment": 30, + | "credit": 70 + | }, + | "balance": { + | "debit": 200, + | "credit": 0 + | }, + | "dueDate": "2017-07-22", + | "isSpecified": false, + | "itemType": "1481", + | "codeText": "NON_RTI_CIS_FIXED_PENALTY" + | } + | ], + | "totals": { + | "charges": { + | "debit": 0, + | "credit": 0 + | }, + | "cleared": { + | "cleared": 0, + | "payment": 0, + | "credit": 0 + | }, + | "balance": { + | "debit": 0, + | "credit": 0 + | } + | } + | }, + | "unallocated": 2000 + |} + """.stripMargin + + val expectedAnnualStatementJson = Json.parse( + """ + |{ + | "taxYear": { + | "year": "2017-18", + | "firstDay": "2017-04-06", + | "lastDay": "2018-04-05" + | }, + | "nonRtiCharges": [ + | { + | "code": "NON_RTI_CIS_FIXED_PENALTY", + | "amount": 300, + | "clearedByCredits": 70, + | "clearedByPayments": 30, + | "balance": 200, + | "dueDate": "2017-07-22" + | } + | ], + | "_embedded": { + | "earlierYearUpdate": { + | "amount": 700, + | "clearedByCredits": 200, + | "clearedByPayments": 300, + | "balance": 200, + | "dueDate": "2017-04-22" + | }, + | "rtiCharges": [ + | { + | "taxMonth": { + | "number": 7, + | "firstDay": "2017-10-06", + | "lastDay": "2017-11-05" + | }, + | "amount": 1200, + | "clearedByCredits": 0, + | "clearedByPayments": 0, + | "balance": 1200, + | "dueDate": "2017-11-22", + | "isSpecified": false, + | "_links": { + | "self": { + | "href": "/organisations/paye/840/GZ00064/statements/2017-18/7" + | } + | } + | }, + | { + | "taxMonth": { + | "number": 3, + | "firstDay": "2017-06-06", + | "lastDay": "2017-07-05" + | }, + | "amount": 700, + | "clearedByCredits": 200, + | "clearedByPayments": 300, + | "balance": 200, + | "dueDate": "2017-07-22", + | "isSpecified": true, + | "_links": { + | "self": { + | "href": "/organisations/paye/840/GZ00064/statements/2017-18/3" + | } + | } + | } + | ] + | }, + | "_links": { + | "empRefs": { + | "href": "/organisations/paye" + | }, + | "statements": { + | "href": "/organisations/paye/840/GZ00064/statements" + | }, + | "self": { + | "href": "/organisations/paye/840/GZ00064/statements/2017-18" + | }, + | "next": { + | "href": "/organisations/paye/840/GZ00064/statements/2018-19" + | }, + | "previous": { + | "href": "/organisations/paye/840/GZ00064/statements/2016-17" + | } + | } + |} + """.stripMargin) + + def authorisedEnrolmentJson(empRef: EmpRef): String = + s""" + |{ + | "authorisedEnrolments": [ + | { + | "key": "IR-PAYE", + | "identifiers": [ + | { + | "key": "TaxOfficeNumber", + | "value": "${empRef.taxOfficeNumber}" + | }, + | { + | "key": "TaxOfficeReference", + | "value": "${empRef.taxOfficeReference}" + | }], + | "state": "Activated", + | "confidenceLevel": 0, + | "delegatedAuthRule": "epaye-auth", + | "enrolment": "IR-PAYE" + | } + | ] + |} + """.stripMargin + +} diff --git a/test/contract/RestAssertions.scala b/test/common/RestAssertions.scala similarity index 61% rename from test/contract/RestAssertions.scala rename to test/common/RestAssertions.scala index 3b4ff3c..dc27182 100644 --- a/test/contract/RestAssertions.scala +++ b/test/common/RestAssertions.scala @@ -14,12 +14,11 @@ * limitations under the License. */ -package contract +package common -import com.fasterxml.jackson.databind.ObjectMapper -import com.github.fge.jsonschema.main.JsonSchemaFactory import com.github.tomakehurst.wiremock.client.WireMock._ import org.scalatest.Matchers +import play.api.libs.json.{JsValue, Json} import play.api.libs.ws.{WSClient, WSRequest} import uk.gov.hmrc.domain.EmpRef import uk.gov.hmrc.play.http.HttpResponse @@ -43,30 +42,23 @@ class ClientGivens(empRef: EmpRef) { def and(): ClientGivens = this - def epayeTotalsReturns(owed: BigDecimal): ClientGivens = { + def epayeTotalsReturns(body: String): ClientGivens = { val response = aResponse() - val body = - """ - |{ - | "rti": { - | "totals": { - | "balance": { - | "credit": 10, - | "debit": 20 - | } - | } - | }, - | "nonRti": { - | "totals": { - | "balance": { - | "credit": 10, - | "debit": 20 - | } - | } - | } - |} - """.stripMargin + response + .withBody(body) + .withHeader("Content-Type", "application/json") + .withStatus(200) + + stubFor( + get(urlPathEqualTo(s"/epaye/${empRef.encodedValue}/api/v1/annual-statement")).willReturn(response) + ) + + this + } + + def epayeAnnualStatementReturns(body: String): ClientGivens = { + val response = aResponse() response .withBody(body) @@ -82,48 +74,26 @@ class ClientGivens(empRef: EmpRef) { def isAuthorized: ClientGivens = { - val responseBody = - s""" - |{ - | "authorisedEnrolments": [ - | { - | "key": "IR-PAYE", - | "identifiers": [ - | { - | "key": "TaxOfficeNumber", - | "value": "${empRef.taxOfficeNumber}" - | }, - | { - | "key": "TaxOfficeReference", - | "value": "${empRef.taxOfficeReference}" - | }], - | "state": "Activated", - | "confidenceLevel": 0, - | "delegatedAuthRule": "epaye-auth", - | "enrolment": "IR-PAYE" - | } - | ] - |} - """.stripMargin - - val response = aResponse().withBody(responseBody).withStatus(200) + val response = aResponse().withBody(Fixtures.authorisedEnrolmentJson(empRef)).withStatus(200) stubFor( post(urlPathEqualTo(s"/auth/authorise")).willReturn(response) ) this } - } class Assertions(response: HttpResponse) extends Matchers { + def bodyIsOfJson(json: JsValue) = { + Json.parse(response.body) shouldEqual json + this + } def bodyIsOfSchema(schemaPath: String): Unit = { - val validator = JsonSchemaFactory.byDefault().getJsonSchema(schemaPath) - val report = validator.validate(new ObjectMapper().readTree(response.body), true) + val report = Schema(schemaPath).validate(response.body) - withClue(report.toString){ report.isSuccess shouldBe true } + withClue(report.toString) { report.isSuccess shouldBe true } } def statusCodeIs(statusCode: Int): Assertions = { diff --git a/test/common/Schema.scala b/test/common/Schema.scala new file mode 100644 index 0000000..4040306 --- /dev/null +++ b/test/common/Schema.scala @@ -0,0 +1,28 @@ +/* + * Copyright 2017 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 common + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.fge.jsonschema.core.report.ProcessingReport +import com.github.fge.jsonschema.main.JsonSchemaFactory + +case class Schema(schemaPath: String) { + private val validator = JsonSchemaFactory.byDefault().getJsonSchema(schemaPath) + + def validate(json: String): ProcessingReport = + validator.validate(new ObjectMapper().readTree(json), true) +} \ No newline at end of file diff --git a/test/common/WSClientSetup.scala b/test/common/WSClientSetup.scala new file mode 100644 index 0000000..1d83e6e --- /dev/null +++ b/test/common/WSClientSetup.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2017 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 common + +import org.scalatest.Suite +import org.scalatestplus.play.OneServerPerSuite +import play.api.libs.ws.WSClient +import uk.gov.hmrc.play.http.HeaderCarrier + +trait WSClientSetup extends OneServerPerSuite { self: Suite => + val baseUrl = s"http://localhost:$port" + + implicit val hc = HeaderCarrier() + + implicit val wsClient: WSClient = app.injector.instanceOf[WSClient] +} diff --git a/test/contract/WiremockSetup.scala b/test/common/WiremockSetup.scala similarity index 59% rename from test/contract/WiremockSetup.scala rename to test/common/WiremockSetup.scala index a3348e0..b84fd58 100644 --- a/test/contract/WiremockSetup.scala +++ b/test/common/WiremockSetup.scala @@ -14,42 +14,22 @@ * limitations under the License. */ -package contract +package common import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig -import org.scalatest.concurrent.{Eventually, IntegrationPatience, ScalaFutures} -import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpec} -import org.scalatestplus.play.OneServerPerSuite -import play.api.libs.ws.WSClient -import uk.gov.hmrc.play.http.HeaderCarrier +import org.scalatest._ -trait WiremockSetup - extends WordSpec - with Matchers - with OneServerPerSuite - with Eventually - with ScalaFutures - with BeforeAndAfterEach - with IntegrationPatience - with BeforeAndAfterAll - with RestAssertions { - - val baseUrl = s"http://localhost:$port" - - implicit val hc = HeaderCarrier() - - implicit val wsClient: WSClient = app.injector.instanceOf[WSClient] +trait WiremockSetup extends BeforeAndAfterEach with BeforeAndAfterAll { self: Suite => lazy val WIREMOCK_PORT: Int = 22222 - protected val wiremockBaseUrl: String = s"http://localhost:$WIREMOCK_PORT" + val wiremockBaseUrl: String = s"http://localhost:$WIREMOCK_PORT" val wireMockServer = new WireMockServer(wireMockConfig().port(WIREMOCK_PORT)) override def beforeAll(): Unit = { - wireMockServer.stop() wireMockServer.start() WireMock.configureFor("localhost", WIREMOCK_PORT) } @@ -58,6 +38,10 @@ trait WiremockSetup WireMock.reset() } + override protected def afterAll(): Unit = { + super.afterAll() + wireMockServer.stop() + } } diff --git a/test/contract/GetAnnualStatementSpec.scala b/test/contract/GetAnnualStatementSpec.scala new file mode 100644 index 0000000..26ca443 --- /dev/null +++ b/test/contract/GetAnnualStatementSpec.scala @@ -0,0 +1,74 @@ +/* + * Copyright 2017 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 contract + +import common._ +import org.scalatest.{Matchers, WordSpec} +import play.api.Application +import play.api.inject.bind +import play.api.inject.guice.GuiceApplicationBuilder +import play.api.routing.Router +import uk.gov.hmrc.epayeapi.router.RoutesProvider + +import scala.io.Source + +class GetAnnualStatementSpec + extends WordSpec + with Matchers + with WSClientSetup + with WiremockSetup + with EmpRefGenerator + with RestAssertions { + + override implicit lazy val app: Application = + new GuiceApplicationBuilder().overrides(bind[Router].toProvider[RoutesProvider]).build() + + val annualStatementSchemaPath: String = + s"${app.path.toURI}/resources/public/api/conf/1.0/schemas/AnnualStatement.get.schema.json" + + "/organisations/epaye/{ton}/{tor}/statements/{taxYear}" should { + + "returns a response body that conforms with the Annual Statement schema" in { + + val empRef = randomEmpRef() + + val annualStatementUrl = + s"$baseUrl/${empRef.taxOfficeNumber}/${empRef.taxOfficeReference}/statements/2016-17" + + given() + .clientWith(empRef).isAuthorized + .and().epayeAnnualStatementReturns(Fixtures.epayeAnnualStatement) + .when + .get(annualStatementUrl).withAuthHeader() + .thenAssertThat() + .bodyIsOfSchema(annualStatementSchemaPath) + } + } + + "The provided example for the Annual Statement" should { + "conform to the schema" in { + val annualStatementExampleJson: String = + Source.fromURL(s"${app.path.toURI}/resources/public/api/conf/1.0/examples/AnnualStatement.get.json") + .getLines.mkString + + val report = Schema(annualStatementSchemaPath).validate(annualStatementExampleJson) + + withClue(report.toString) { report.isSuccess shouldBe true } + } + } +} + diff --git a/test/contract/TotalsSpec.scala b/test/contract/GetSummarySpec.scala similarity index 80% rename from test/contract/TotalsSpec.scala rename to test/contract/GetSummarySpec.scala index 6f69d21..0583fb3 100644 --- a/test/contract/TotalsSpec.scala +++ b/test/contract/GetSummarySpec.scala @@ -16,20 +16,28 @@ package contract +import common._ +import org.scalatest.{Matchers, WordSpec} import play.api.Application import play.api.inject.bind import play.api.inject.guice.GuiceApplicationBuilder import play.api.routing.Router import uk.gov.hmrc.epayeapi.router.RoutesProvider -class TotalsSpec extends WiremockSetup with EmpRefGenerator with RestAssertions { +class GetSummarySpec + extends WordSpec + with Matchers + with WSClientSetup + with WiremockSetup + with EmpRefGenerator + with RestAssertions{ override implicit lazy val app: Application = new GuiceApplicationBuilder().overrides(bind[Router].toProvider[RoutesProvider]).build() "/organisation/epaye/{ton}/{tor}/" should { - "return 200 OK on active enrolments given no debit" in { + "returns a response body that conforms with the Summary schema" in { val empRef = randomEmpRef() val totalsUrl = @@ -39,7 +47,7 @@ class TotalsSpec extends WiremockSetup with EmpRefGenerator with RestAssertions given() .clientWith(empRef).isAuthorized - .and().epayeTotalsReturns(owed = 0) + .and().epayeTotalsReturns(Fixtures.epayeAnnualStatement) .when .get(totalsUrl).withAuthHeader() .thenAssertThat() diff --git a/test/integration/GetAnnualStatementSpec.scala b/test/integration/GetAnnualStatementSpec.scala new file mode 100644 index 0000000..2601861 --- /dev/null +++ b/test/integration/GetAnnualStatementSpec.scala @@ -0,0 +1,56 @@ +/* + * Copyright 2017 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 integration + +import common._ +import contract._ +import org.scalatest.{Matchers, WordSpec} +import play.api.Application +import play.api.inject.bind +import play.api.inject.guice.GuiceApplicationBuilder +import play.api.libs.json.Json +import play.api.routing.Router +import uk.gov.hmrc.domain.EmpRef +import uk.gov.hmrc.epayeapi.router.RoutesProvider + +class GetAnnualStatementSpec extends WordSpec + with Matchers + with WSClientSetup + with WiremockSetup + with EmpRefGenerator + with RestAssertions { + + override implicit lazy val app: Application = + new GuiceApplicationBuilder().overrides(bind[Router].toProvider[RoutesProvider]).build() + + "AnnualStatement API should return a statement " should { + "containing EYU data" in { + val empRef = EmpRef("840", "GZ00064") + + val annualStatementUrl = + s"$baseUrl/${empRef.taxOfficeNumber}/${empRef.taxOfficeReference}/statements/2017-18" + + given() + .clientWith(empRef).isAuthorized + .and().epayeAnnualStatementReturns(Fixtures.epayeAnnualStatement) + .when() + .get(annualStatementUrl).withAuthHeader() + .thenAssertThat() + .bodyIsOfJson(Fixtures.expectedAnnualStatementJson) + } + } +} diff --git a/test/uk/gov/hmrc/epayeapi/connectors/EpayeConnectorSpec.scala b/test/uk/gov/hmrc/epayeapi/connectors/EpayeConnectorSpec.scala index 6569ef9..baf9589 100644 --- a/test/uk/gov/hmrc/epayeapi/connectors/EpayeConnectorSpec.scala +++ b/test/uk/gov/hmrc/epayeapi/connectors/EpayeConnectorSpec.scala @@ -28,8 +28,11 @@ import uk.gov.hmrc.play.http.ws.WSHttp import uk.gov.hmrc.play.http.{HeaderCarrier, HttpResponse} import uk.gov.hmrc.play.test.UnitSpec +import scala.concurrent.duration._ +import scala.concurrent.Await import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future.successful +import scala.concurrent.duration.Duration class EpayeConnectorSpec extends UnitSpec with MockitoSugar with ScalaFutures { @@ -41,7 +44,8 @@ class EpayeConnectorSpec extends UnitSpec with MockitoSugar with ScalaFutures { val empRef = EmpRef("123", "456") val urlTotals = s"${config.baseUrl}/epaye/${empRef.encodedValue}/api/v1/annual-statement" val urlTotalsByType = s"${config.baseUrl}/epaye/${empRef.encodedValue}/api/v1/totals/by-type" - val urlAnnualStatement = s"${config.baseUrl}/epaye/${empRef.encodedValue}/api/v1/annual-statement" + def urlAnnualStatement(taxYear: TaxYear): String = + s"${config.baseUrl}/epaye/${empRef.encodedValue}/api/v1/annual-statement/${taxYear.asString}" } "EpayeConnector" should { @@ -73,7 +77,7 @@ class EpayeConnectorSpec extends UnitSpec with MockitoSugar with ScalaFutures { } } - connector.getTotal(empRef, hc).futureValue shouldBe + Await.result(connector.getTotal(empRef, hc), 2.seconds) shouldBe ApiSuccess( EpayeTotalsResponse( EpayeTotalsItem(EpayeTotals(DebitAndCredit(100, 0))), @@ -81,24 +85,28 @@ class EpayeConnectorSpec extends UnitSpec with MockitoSugar with ScalaFutures { ) ) } + "retrieve summary for a given empRef" in new Setup { - when(connector.http.GET(urlAnnualStatement)).thenReturn { + val taxYear = TaxYear(2016) + + when(connector.http.GET(urlAnnualStatement(taxYear))).thenReturn { successful { HttpResponse(Status.OK, responseString = Some(JsonFixtures.annualStatements.annualStatement)) } } - connector.getAnnualSummary(empRef, hc, None).futureValue shouldBe + connector.getAnnualStatement(empRef, taxYear, hc).futureValue shouldBe ApiSuccess( - AnnualSummaryResponse( - AnnualSummary( + EpayeAnnualStatement( + rti = AnnualStatementTable( List(LineItem(TaxYear(2017), Some(TaxMonth(1)), DebitAndCredit(100.2, 0), Cleared(0, 0), DebitAndCredit(100.2, 0), new LocalDate(2017, 5, 22), isSpecified = false, codeText = None)), AnnualTotal(DebitAndCredit(100.2, 0), Cleared(0, 0), DebitAndCredit(100.2, 0)) ), - AnnualSummary( + nonRti = AnnualStatementTable( List(LineItem(TaxYear(2017), None, DebitAndCredit(20.0, 0), Cleared(0, 0), DebitAndCredit(20.0, 0), new LocalDate(2018, 2, 22), false, Some("P11D_CLASS_1A_CHARGE"))), AnnualTotal(DebitAndCredit(20.0, 0), Cleared(0, 0), DebitAndCredit(20.0, 0)) - ) + ), + unallocated = None ) ) } diff --git a/test/uk/gov/hmrc/epayeapi/models/JsonFixtures.scala b/test/uk/gov/hmrc/epayeapi/models/JsonFixtures.scala index b0f9e04..0b90001 100644 --- a/test/uk/gov/hmrc/epayeapi/models/JsonFixtures.scala +++ b/test/uk/gov/hmrc/epayeapi/models/JsonFixtures.scala @@ -16,6 +16,11 @@ package uk.gov.hmrc.epayeapi.models +import org.joda.time.LocalDate +import uk.gov.hmrc.domain.EmpRef +import uk.gov.hmrc.epayeapi.models.in._ +import uk.gov.hmrc.epayeapi.models.out._ + object JsonFixtures { object annualStatements { lazy val annualStatement: String = getJsonData("epaye/annual-statement/annual-statement.json") @@ -23,4 +28,44 @@ object JsonFixtures { def getJsonData(fname: String): String = scala.io.Source.fromURL(getClass.getResource(s"/${fname}"), "utf-8").mkString("") + + val baseUrl = "/organisations/paye" + + def baseUrlFor(empRef: EmpRef): String = + s"$baseUrl/${empRef.taxOfficeNumber}/${empRef.taxOfficeReference}" + + def emptyAnnualStatementJsonWith(empRef: EmpRef, taxYear: TaxYear): AnnualStatementJson = + AnnualStatementJson( + taxYear = TaxYearJson(taxYear.asString, taxYear.firstDay, taxYear.lastDay), + _embedded = EmbeddedRtiChargesJson(None, Seq()), + nonRtiCharges = Seq(), + _links = AnnualStatementLinksJson( + empRefs = Link(baseUrl), + statements = Link(s"${baseUrlFor(empRef)}/statements"), + self = Link(s"${baseUrlFor(empRef)}/statements/${taxYear.asString}"), + next = Link(s"{${baseUrlFor(empRef)}/statements/${taxYear.next.asString}"), + previous = Link(s"${baseUrlFor(empRef)}/statements/${taxYear.previous.asString}") + ) + ) + + val emptyEpayeAnnualStatement = + EpayeAnnualStatement( + rti = AnnualStatementTable( + lineItems = Seq(), + totals = AnnualTotal( + charges = DebitAndCredit(0, 0), + cleared = Cleared(0, 0), + balance = DebitAndCredit(0, 0) + ) + ), + nonRti = AnnualStatementTable( + lineItems = Seq(), + totals = AnnualTotal( + charges = DebitAndCredit(0, 0), + cleared = Cleared(0), + balance = DebitAndCredit(0, 0) + ) + ), + unallocated = None + ) } diff --git a/test/uk/gov/hmrc/epayeapi/models/in/TaxYearSpec.scala b/test/uk/gov/hmrc/epayeapi/models/in/TaxYearSpec.scala index a8c0f06..c9f54f6 100644 --- a/test/uk/gov/hmrc/epayeapi/models/in/TaxYearSpec.scala +++ b/test/uk/gov/hmrc/epayeapi/models/in/TaxYearSpec.scala @@ -17,27 +17,26 @@ package uk.gov.hmrc.epayeapi.models.in import org.scalatest.{Matchers, WordSpec} -import TaxYear.extractTaxYear class TaxYearSpec extends WordSpec with Matchers { "Extract tax year" should { "retrieve nothing if tax year query string is empty" in { - extractTaxYear("") shouldBe None + ExtractTaxYear.unapply("") shouldBe None } "retrieve tax year if it matches pattern yyyy-yy" in { - extractTaxYear("2017-18") shouldBe Some(TaxYear(2017)) + ExtractTaxYear.unapply("2017-18") shouldBe Some(TaxYear(2017)) } "retrieve nothing if the years don't match" in { - extractTaxYear("2017-19") shouldBe None + ExtractTaxYear.unapply("2017-19") shouldBe None } "retrieve nothing if the years are reversed" in { - extractTaxYear("2018-17") + ExtractTaxYear.unapply("2018-17") } "retrieve nothing if it matches pattern yyyy" in { - extractTaxYear("2017") shouldBe None + ExtractTaxYear.unapply("2017") shouldBe None } "retrieve nothing if tax year does not match yyyy-yy" in { - extractTaxYear("abc") shouldBe None + ExtractTaxYear.unapply("abc") shouldBe None } } } diff --git a/test/uk/gov/hmrc/epayeapi/models/out/AnnualStatementJsonSpec.scala b/test/uk/gov/hmrc/epayeapi/models/out/AnnualStatementJsonSpec.scala new file mode 100644 index 0000000..fbeeb6e --- /dev/null +++ b/test/uk/gov/hmrc/epayeapi/models/out/AnnualStatementJsonSpec.scala @@ -0,0 +1,165 @@ +/* + * Copyright 2017 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 uk.gov.hmrc.epayeapi.models.out + +import org.joda.time.LocalDate +import org.scalatest.{Matchers, WordSpec} +import uk.gov.hmrc.domain.EmpRef +import uk.gov.hmrc.epayeapi.models.JsonFixtures.{baseUrl, baseUrlFor, emptyAnnualStatementJsonWith, emptyEpayeAnnualStatement} +import uk.gov.hmrc.epayeapi.models.in._ + +class AnnualStatementJsonSpec extends WordSpec with Matchers { + val empRef = EmpRef("123", "AB45678") + val taxYear = TaxYear(2016) + val taxMonth = TaxMonth(2) + val dueDate = new LocalDate(2017, 5, 22) + + "AnnualStatementJson.apply.taxYear" should { + "contain the right taxYear" in { + AnnualStatementJson(empRef, taxYear, emptyEpayeAnnualStatement).taxYear shouldBe TaxYearJson(taxYear.asString, taxYear.firstDay, taxYear.lastDay) + } + } + + "AnnualStatementJson.apply._links" should { + "contain the right links" in { + AnnualStatementJson(empRef, taxYear, emptyEpayeAnnualStatement)._links shouldBe + AnnualStatementLinksJson( + empRefs = Link(baseUrl), + statements = Link(s"${baseUrlFor(empRef)}/statements"), + self = Link(s"${baseUrlFor(empRef)}/statements/${taxYear.asString}"), + next = Link(s"${baseUrlFor(empRef)}/statements/${taxYear.next.asString}"), + previous = Link(s"${baseUrlFor(empRef)}/statements/${taxYear.previous.asString}") + ) + } + } + + "AnnualStatementJson.apply._embedded.earlierYearUpdate" should { + "contain the earlier year update if it is present" in { + val emptyTotals = AnnualTotal( + charges = DebitAndCredit(), + cleared = Cleared(), + balance = DebitAndCredit() + ) + + val epayeAnnualStatement = + emptyEpayeAnnualStatement + .copy( + rti = + AnnualStatementTable( + lineItems = Seq( + LineItem( + taxYear, + None, + DebitAndCredit(100), + Cleared(10, 20), + DebitAndCredit(100 - 20 - 10), + dueDate, + codeText = None, + itemType = Some("eyu") + ) + ), + totals = emptyTotals + ) + ) + + AnnualStatementJson(empRef, taxYear, epayeAnnualStatement)._embedded.earlierYearUpdate shouldBe + Some(EarlierYearUpdateJson( + amount = 100, + clearedByCredits = 20, + clearedByPayments = 10, + balance = 100 - 10 - 20, + dueDate = dueDate + )) + } + "return a None if it is not present" in { + AnnualStatementJson(empRef, taxYear, emptyEpayeAnnualStatement)._embedded.earlierYearUpdate shouldBe None + } + } + + "MonthlyChargesJson.from(lineItem)" should { + "convert an rti charge from the epaye annual statement" in { + val taxMonth = TaxMonth(2) + + val lineItem = + LineItem( + taxYear = taxYear, + taxMonth = Some(taxMonth), + charges = DebitAndCredit(100, 0), + cleared = Cleared(payment = 10, credit = 20), + balance = DebitAndCredit(100 - 30, 0), + dueDate = dueDate, + isSpecified = true, + codeText = None + ) + + MonthlyChargesJson.from(lineItem, empRef, taxYear) shouldBe + Some(MonthlyChargesJson( + taxMonth = TaxMonthJson(taxMonth.month, taxMonth.firstDay(taxYear), taxMonth.lastDay(taxYear)), + amount = 100, + clearedByCredits = 20, + clearedByPayments = 10, + balance = 100 - 10 - 20, + dueDate = dueDate, + isSpecified = true, + _links = SelfLink(Link(s"${baseUrlFor(empRef)}/statements/${taxYear.asString}/${taxMonth.month}")) + )) + } + "return a None if the taxMonth field is None" in { + + val lineItem = + LineItem( + taxYear = taxYear, + taxMonth = None, + charges = DebitAndCredit(100, 0), + cleared = Cleared(payment = 10, credit = 20), + balance = DebitAndCredit(100 - 30, 0), + dueDate = dueDate, + codeText = None + ) + + MonthlyChargesJson.from(lineItem, empRef, taxYear) shouldBe None + } + } + + "NonRtiChargesJson.from(lineItem)" should { + "convert an non rti charge from the epaye annual statement" in { + val code = "SOME_TEXT" + + val lineItem = + LineItem( + taxYear = taxYear, + taxMonth = None, + charges = DebitAndCredit(100, 0), + cleared = Cleared(payment = 10, credit = 20), + balance = DebitAndCredit(100 - 30, 0), + dueDate = dueDate, + codeText = Some(code) + ) + + NonRtiChargesJson.from(lineItem, taxYear) shouldBe + Some(NonRtiChargesJson( + code = code, + amount = 100, + clearedByCredits = 20, + clearedByPayments = 10, + balance = 100 - 10 - 20, + dueDate = dueDate + )) + } + } +} +