Skip to content

Commit 8c0dc70

Browse files
authored
MTDSA-29919 Add submittedOn field for Retrieve POA and allow start date before tax year supplied (#195)
1 parent 0a6f679 commit 8c0dc70

File tree

10 files changed

+288
-69
lines changed

10 files changed

+288
-69
lines changed

app/api/controllers/validators/resolvers/ResolveDateRange.scala

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@ case class ResolveDateRange(startDateFormatError: MtdError = StartDateFormatErro
4242
def withDatesLimitedTo(minDate: LocalDate,
4343
minError: MtdError = startDateFormatError,
4444
maxDate: LocalDate,
45-
maxError: MtdError = endDateFormatError): Resolver[(String, String), DateRange] =
45+
maxError: MtdError = endDateFormatError,
46+
enforceStartOnOrAfterMin: Boolean = true): Resolver[(String, String), DateRange] =
4647
resolver thenValidate ResolveDateRange.datesLimitedTo(
4748
minDate,
4849
minError,
4950
maxDate,
50-
maxError
51+
maxError,
52+
enforceStartOnOrAfterMin
5153
)
5254

5355
private def resolveDateRange(parsedStartDate: LocalDate, parsedEndDate: LocalDate): Validated[Seq[MtdError], DateRange] =
@@ -61,12 +63,22 @@ case class ResolveDateRange(startDateFormatError: MtdError = StartDateFormatErro
6163

6264
object ResolveDateRange extends ResolverSupport {
6365

64-
def datesLimitedTo(minDate: LocalDate, minError: => MtdError, maxDate: LocalDate, maxError: => MtdError): Validator[DateRange] =
65-
combinedValidator[DateRange](
66-
satisfies(minError)(_.startDate >= minDate),
67-
satisfies(minError)(_.startDate <= maxDate),
68-
satisfies(maxError)(_.endDate <= maxDate),
69-
satisfies(maxError)(_.endDate >= minDate)
70-
)
66+
def datesLimitedTo(minDate: LocalDate,
67+
minError: => MtdError,
68+
maxDate: LocalDate,
69+
maxError: => MtdError,
70+
enforceStartOnOrAfterMin: Boolean): Validator[DateRange] = {
71+
val maybeStartOnOrAfterMin: Option[Validator[DateRange]] =
72+
if (enforceStartOnOrAfterMin) Some(satisfies(minError)(_.startDate >= minDate)) else None
73+
74+
val validators: List[Validator[DateRange]] = List(
75+
maybeStartOnOrAfterMin,
76+
Some[Validator[DateRange]](satisfies(minError)(_.startDate <= maxDate)),
77+
Some[Validator[DateRange]](satisfies(maxError)(_.endDate <= maxDate)),
78+
Some[Validator[DateRange]](satisfies(maxError)(_.endDate >= minDate))
79+
).flatten
80+
81+
combinedValidator(validators.head, validators.tail: _*)
82+
}
7183

7284
}

app/api/models/domain/Timestamp.scala

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2025 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package api.models.domain
18+
19+
import play.api.libs.json.{JsString, Reads, Writes}
20+
21+
import java.time.format.DateTimeFormatter
22+
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
23+
import java.time.{ZoneId, ZonedDateTime}
24+
25+
case class Timestamp private (value: String) extends AnyVal {
26+
override def toString: String = value
27+
}
28+
29+
object Timestamp {
30+
31+
private val formatter =
32+
DateTimeFormatter
33+
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
34+
.withZone(ZoneId.of("UTC"))
35+
36+
/** Adds milliseconds to the timestamp string if not already present.
37+
*/
38+
def apply(value: String): Timestamp = {
39+
val ts = ISO_DATE_TIME.parse(value)
40+
val dt = ZonedDateTime.from(ts)
41+
val str = dt.format(formatter)
42+
new Timestamp(str)
43+
}
44+
45+
implicit val reads: Reads[Timestamp] = Reads.of[String].map(Timestamp(_))
46+
implicit val writes: Writes[Timestamp] = ts => JsString(ts.value)
47+
}

app/v2/createUpdatePeriodsOfAccount/CreateUpdatePeriodsOfAccountRulesValidator.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ object CreateUpdatePeriodsOfAccountRulesValidator extends RulesValidator[CreateU
6464
minDate = taxYear.startDate,
6565
minError = RuleStartDateError.withPath(startDatePath),
6666
maxDate = taxYear.endDate,
67-
maxError = RuleEndDateError.withPath(endDatePath)
67+
maxError = RuleEndDateError.withPath(endDatePath),
68+
enforceStartOnOrAfterMin = false
6869
)(periodOfAccountDates.startDate -> periodOfAccountDates.endDate)
6970
}
7071

it/v2/createUpdatePeriodsOfAccount/CreateUpdatePeriodsOfAccountControllerISpec.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ class CreateUpdatePeriodsOfAccountControllerISpec extends IntegrationBaseSpec wi
6464
| "endDate": "2025-07-05"
6565
| },
6666
| {
67-
| "startDate": "2024-07-06",
68-
| "endDate": "2024-10-05"
67+
| "startDate": "2026-04-06",
68+
| "endDate": "2026-07-05"
6969
| }
7070
| ]
7171
|}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2+
"submittedOn": "2025-08-24T14:15:22.802Z",
23
"periodsOfAccount": false
34
}

resources/public/api/conf/2.0/examples/retrievePeriodsOfAccount/periodOfAccountTrueResponse.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"submittedOn": "2025-08-24T14:15:22.802Z",
23
"periodsOfAccount": true,
34
"periodsOfAccountDates": [
45
{

resources/public/api/conf/2.0/schemas/retrievePeriodsOfAccount/response.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
"description": "Retrieve Periods of Account Response",
55
"type": "object",
66
"properties": {
7+
"submittedOn": {
8+
"type": "string",
9+
"description": "The date and time of the submission. Must conform to the format YYYY-MM-DDThh:mm:ss.SSSZ",
10+
"format": "date-time",
11+
"example": "2025-08-24T14:15:22.802Z"
12+
},
713
"periodsOfAccount": {
814
"type": "boolean",
915
"description": "Indicates whether the customer has periods of account.\n\nIf this value is true, then periodsOfAccountDates will be returned.",
@@ -33,6 +39,6 @@
3339
}
3440
}
3541
},
36-
"required": ["periodsOfAccount"],
42+
"required": ["submittedOn", "periodsOfAccount"],
3743
"additionalProperties": false
3844
}

test/api/controllers/validators/resolvers/ResolveDateRangeSpec.scala

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package api.controllers.validators.resolvers
1818

19-
import api.controllers.validators.resolvers
2019
import api.models.domain.DateRange
2120
import api.models.errors.{EndDateFormatError, MtdError, RuleEndBeforeStartDateError, StartDateFormatError}
2221
import cats.data.Validated
@@ -103,37 +102,62 @@ class ResolveDateRangeSpec extends UnitSpec {
103102
}
104103
}
105104

106-
"ResolveDateRange datesLimitedTo" should {
107-
val minDate: LocalDate = LocalDate.parse("2000-02-01")
108-
val maxDate: LocalDate = LocalDate.parse("2000-02-10")
109-
val validator: resolvers.ResolveDateRange.Validator[DateRange] = ResolveDateRange.datesLimitedTo(
105+
"ResolveDateRange datesLimitedTo" when {
106+
val minDate: LocalDate = LocalDate.parse("2000-02-01")
107+
val maxDate: LocalDate = LocalDate.parse("2000-02-10")
108+
val tooEarly: LocalDate = minDate.minusDays(1)
109+
val tooLate: LocalDate = maxDate.plusDays(1)
110+
111+
def validator(enforceStartOnOrAfterMin: Boolean): ResolveDateRange.Validator[DateRange] = ResolveDateRange.datesLimitedTo(
110112
minDate = minDate,
111113
minError = startDateFormatError,
112114
maxDate = maxDate,
113-
maxError = endDateFormatError
115+
maxError = endDateFormatError,
116+
enforceStartOnOrAfterMin = enforceStartOnOrAfterMin
114117
)
115118

116-
val tooEarly: LocalDate = minDate.minusDays(1)
117-
val tooLate: LocalDate = maxDate.plusDays(1)
119+
"enforceStartOnOrAfterMin is true" should {
120+
"allow min and max dates" in {
121+
val result: Option[Seq[MtdError]] = validator(true)(DateRange(minDate, maxDate))
122+
result shouldBe None
123+
}
118124

119-
"allow min and max dates" in {
120-
val result: Option[Seq[MtdError]] = validator(DateRange(minDate, maxDate))
121-
result shouldBe None
122-
}
125+
"disallow dates earlier than min or later than max" in {
126+
val result: Option[Seq[MtdError]] = validator(true)(DateRange(tooEarly, tooLate))
127+
result shouldBe Some(List(startDateFormatError, endDateFormatError))
128+
}
123129

124-
"disallow dates earlier than min or later than max" in {
125-
val result: Option[Seq[MtdError]] = validator(DateRange(tooEarly, tooLate))
126-
result shouldBe Some(List(startDateFormatError, endDateFormatError))
127-
}
130+
"disallow dates later than max" in {
131+
val result: Option[Seq[MtdError]] = validator(true)(DateRange(tooLate, tooLate))
132+
result shouldBe Some(List(startDateFormatError, endDateFormatError))
133+
}
128134

129-
"disallow dates later than max" in {
130-
val result: Option[Seq[MtdError]] = validator(DateRange(tooLate, tooLate))
131-
result shouldBe Some(List(startDateFormatError, endDateFormatError))
135+
"disallow dates earlier than min" in {
136+
val result: Option[Seq[MtdError]] = validator(true)(DateRange(tooEarly, tooEarly))
137+
result shouldBe Some(List(startDateFormatError, endDateFormatError))
138+
}
132139
}
133140

134-
"disallow dates earlier than min" in {
135-
val result: Option[Seq[MtdError]] = validator(DateRange(tooEarly, tooEarly))
136-
result shouldBe Some(List(startDateFormatError, endDateFormatError))
141+
"enforceStartOnOrAfterMin is false" should {
142+
"allow min and max dates" in {
143+
val result: Option[Seq[MtdError]] = validator(false)(DateRange(minDate, maxDate))
144+
result shouldBe None
145+
}
146+
147+
"allow start dates earlier than min" in {
148+
val result: Option[Seq[MtdError]] = validator(false)(DateRange(tooEarly, maxDate))
149+
result shouldBe None
150+
}
151+
152+
"disallow dates later than max" in {
153+
val result: Option[Seq[MtdError]] = validator(false)(DateRange(tooLate, tooLate))
154+
result shouldBe Some(List(startDateFormatError, endDateFormatError))
155+
}
156+
157+
"disallow only end dates earlier than min" in {
158+
val result: Option[Seq[MtdError]] = validator(false)(DateRange(tooEarly, tooEarly))
159+
result shouldBe Some(List(endDateFormatError))
160+
}
137161
}
138162
}
139163

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2025 HM Revenue & Customs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package api.models.domain
18+
19+
import play.api.libs.json.{JsValue, Json, OWrites, Reads}
20+
import support.UnitSpec
21+
22+
class TimestampSpec extends UnitSpec {
23+
24+
private val response: AnyDownstreamResponse = AnyDownstreamResponse(3, "payments", Timestamp("2023-01-20T01:20:30.000Z"))
25+
26+
private val responseJs: JsValue = Json.parse(
27+
"""
28+
|{
29+
| "amount": 3,
30+
| "category": "payments",
31+
| "lastUpdated": "2023-01-20T01:20:30.000Z"
32+
|}
33+
""".stripMargin
34+
)
35+
36+
private val responseJsNoMillis: JsValue = Json.parse(
37+
"""
38+
|{
39+
| "amount": 3,
40+
| "category": "payments",
41+
| "lastUpdated": "2023-01-20T01:20:30Z"
42+
|}
43+
""".stripMargin
44+
)
45+
46+
"Timestamp" when {
47+
".apply" should {
48+
"parse correctly and return a ts with milliseconds" when {
49+
"given a ts without milliseconds" in {
50+
val str: String = "2023-01-20T01:20:30Z"
51+
val result: Timestamp = Timestamp(str)
52+
result.value shouldBe "2023-01-20T01:20:30.000Z"
53+
}
54+
55+
"given a ts with milliseconds" in {
56+
val str: String = "2023-01-20T01:20:30.123Z"
57+
val result: Timestamp = Timestamp(str)
58+
result.value shouldBe str
59+
}
60+
61+
"given a ts with > millisecond precision" in {
62+
val str: String = "2023-01-20T01:20:30.123456789Z"
63+
val result: Timestamp = Timestamp(str)
64+
result.value shouldBe "2023-01-20T01:20:30.123Z"
65+
}
66+
67+
"given the following ts formats" in {
68+
Timestamp("2021-06-17T10:53:38Z").value shouldBe "2021-06-17T10:53:38.000Z"
69+
Timestamp("2021-06-17T10:53:38.1Z").value shouldBe "2021-06-17T10:53:38.100Z"
70+
Timestamp("2021-06-17T10:53:38.12Z").value shouldBe "2021-06-17T10:53:38.120Z"
71+
Timestamp("2021-06-17T10:53:38.123Z").value shouldBe "2021-06-17T10:53:38.123Z"
72+
73+
withClue("more than 3 (4,5,6 ) digit precision") {
74+
Timestamp("2021-06-17T10:53:38.1234Z").value shouldBe "2021-06-17T10:53:38.123Z"
75+
}
76+
}
77+
}
78+
}
79+
80+
"deserialized from a JSON string field" should {
81+
"parse correctly" when {
82+
"the JSON string has milliseconds" in {
83+
val result: AnyDownstreamResponse = responseJs.as[AnyDownstreamResponse]
84+
result shouldBe response
85+
}
86+
87+
"the JSON string has no milliseconds" in {
88+
val result: AnyDownstreamResponse = responseJsNoMillis.as[AnyDownstreamResponse]
89+
result shouldBe response
90+
}
91+
}
92+
}
93+
94+
"serialised to a JSON string field" should {
95+
"serialise correctly" in {
96+
val result: JsValue = Json.toJson(response)
97+
result shouldBe responseJs
98+
}
99+
}
100+
}
101+
102+
private case class AnyDownstreamResponse(amount: Int, category: String, lastUpdated: Timestamp)
103+
104+
private object AnyDownstreamResponse {
105+
implicit val reads: Reads[AnyDownstreamResponse] = Json.reads[AnyDownstreamResponse]
106+
implicit val writes: OWrites[AnyDownstreamResponse] = Json.writes[AnyDownstreamResponse]
107+
}
108+
109+
}

0 commit comments

Comments
 (0)