Skip to content

Conversation

dc-smith
Copy link
Contributor

@dc-smith dc-smith commented Oct 15, 2025

The requirement in PIL-2449 to add additional states to the dynamic notification area requires nontrivial parsing of the transactions we receive from ETMP.

My view is that trying understand the new states alongside the old ones is a level of complexity which necessitates a domain model. This branch aims to introduce one for the financial data; I will raise another branch off the back of this one with the new content, and merge that into this after both are approved to support QA in testing the change. It should be easier to review as two PRs, even if it eventually all gets merged together.

  • Enumerate ETMP references we understand.
  • Model financial transactions as an ADT to support clearer use later on.
  • Move validation of outstanding charges from Service -> Controller to Connector -> Service. This does not introduce any extra checks.
  • Drop any transactions which do not pass validation. This preserves the existing behaviour.

Unrelated - made recent payment threshold configurable to stop scalastyle complaining about a magic number. After rebasing, this had been moved to a Constants file, but I thought it was worth keeping the config. Happy to roll back if we like.

@platops-pr-bot
Copy link

@dc-smith dc-smith changed the title PIL-2449 New notification area states & content PIL-2449 Introduce domain model for financial data Oct 16, 2025
outstandingAmount: Option[BigDecimal],
items: Seq[FinancialItem]
)
case class TaxPeriod(from: LocalDate, to: LocalDate)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TaxPeriod is nearly identical to AccountingPeriod, but I didn't want to go poking around in any more places than I already have.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's worth creating a spike to investigate further and consolidate if possible

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I'll get something into the backlog to review the model, reduce any redundancy, etc. I'll highlight this but keep the scope fairly wide.

Comment on lines +72 to +74
case EtmpMainTransactionRef.UkTaxReturnMain => (UktrMainOutstandingCharge.apply _).tupled(fields)
case EtmpMainTransactionRef.LatePaymentInterest => (LatePaymentInterestOutstandingCharge.apply _).tupled(fields)
case EtmpMainTransactionRef.RepaymentInterest => (RepaymentInterestOutstandingCharge.apply _).tupled(fields)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't particularly like constructing things like this, but I also like being able to distinguish the charges at the type level. Open to suggestions on a cleaner model.

val daysAgoLatestPaymentCleared = ChronoUnit.DAYS.between(latestClearing, LocalDate.now(clock))
daysAgoLatestPaymentCleared <= appConfig.maxDaysAgoToConsiderPaymentAsRecent
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually move this back into FinancialData in the followup PR, which I'll merge into this one.

}
Option.empty[FinancialTransaction]
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of the validations here is taken from the filters which previously existed in FinancialData. There should be only one behaviour change, called out below.

.flatMap(_.dueDate)
.minOption
.toValidNec(IgnoredEtmpTransaction.RequiredValueMissing("dueDate"))
.map(earliestDueDate => OutstandingCharge.FinancialItems(earliestDueDate, responseItems.map(responseItemToDomain)))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We previously made the assumption that the first item in the sequence always has a due date in OutstandingPaymentsController. This:

  • Makes that assumption explicit
  • Slightly tweaks the behaviour to track the earliest due date separately. If they really do always have the same due date, this shouldn't be a change. And if they are different, earliest is likely what we want.

status(result) mustEqual OK
contentAsString(result) mustEqual
view(Seq.empty, pillar2Id, BigDecimal(0), hasOverdueReturnPayment = false)(
view(overdueFinancialSummary, pillar2Id, BigDecimal(1000.00), hasOverdueReturnPayment = true)(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test's scenario no longer matched the name, and the state it was producing (transaction passed in with an outstanding amount of zero) is no longer representable using the domain model.

Comment on lines +118 to 120
object TaxPeriod {
implicit val ordering: Ordering[TaxPeriod] = Ordering.by(_.from)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is equivalent to the ordering previously used in OutstandingPaymentsController where a tuple of (from, to) was sorted (I dug around in the scala stdlib for the implicit Tuple2 Ordering).


}

object FinancialDataConnector {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be better if these were placed under models

private def toOutstandingPaymentsSummaries(financialData: FinancialData): Seq[FinancialSummary] = {
val outstandingCharges = financialData.outstandingCharges

outstandingCharges
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are refactoring things, the val outstandingCharges = financialData.outstandingCharges assignment is not really needed. We can directly use financialData.outstandingCharges here.

Suggested change
outstandingCharges
financialData.outstandingCharges

Comment on lines 77 to +79
.toSeq

FinancialSummary(AccountingPeriod(periodFrom, periodTo), transactionSummaries.sortBy(_.dueDate).reverse)
FinancialSummary(AccountingPeriod(taxPeriod.from, taxPeriod.to), transactionSummaries.sortBy(_.dueDate).reverse)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the sorting from the FinancialSummary to the end of transactionSummaries:

Suggested change
.toSeq
FinancialSummary(AccountingPeriod(periodFrom, periodTo), transactionSummaries.sortBy(_.dueDate).reverse)
FinancialSummary(AccountingPeriod(taxPeriod.from, taxPeriod.to), transactionSummaries.sortBy(_.dueDate).reverse)
.toSeq
.sortBy(_.dueDate)
.reverse
FinancialSummary(AccountingPeriod(taxPeriod.from, taxPeriod.to), transactionSummaries)

Comment on lines 147 to 159
if (financialData.financialTransactions.isEmpty) {
None
} else {
val totalOutstandingAmount = financialData.getTotalOutstandingAmount
val totalOutstandingAmount = financialData.totalOutstandingAmount
val hasOutstandingPayment = financialData.hasOverdueOutstandingPayments(currentDate)
val hasRecentPayment = financialData.hasRecentPayment(currentDate = currentDate)
val hasRecentPayment = financialDataService.hasRecentPayment(financialData)

(hasOutstandingPayment, hasRecentPayment) match {
case (true, _) => Some(Outstanding(totalOutstandingAmount))
case (false, true) if totalOutstandingAmount == 0 => Some(Paid)
case _ => None
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are refactoring a few things, maybe this looks "cleaner"?
Used Option.when and moved the if totalOutstandingAmount == 0 guard to the match:

Suggested change
Option
.when(financialData.financialTransactions.nonEmpty) {
val totalOutstandingAmount: BigDecimal = financialData.totalOutstandingAmount
val hasOutstandingPayment: Boolean = financialData.hasOverdueOutstandingPayments(currentDate)
val hasRecentPayment: Boolean = financialDataService.hasRecentPayment(financialData)
(hasOutstandingPayment, hasRecentPayment, totalOutstandingAmount == 0) match {
case (true, _, _) => Some(Outstanding(financialData.totalOutstandingAmount))
case (false, true, true) => Some(Paid)
case _ => None
}
}
.flatten

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to hold off on this for now, if that's OK. The next PR introduces a PaymentState model which is then used to determine both the notification banner and the payment tag, so this method is significantly tidied up there anyway.

case object ActiveAccount extends AccountStatus
case object InactiveAccount extends AccountStatus

val values = findValues
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val values = findValues
val values: IndexedSeq[AccountStatus] = findValues

Adding type annotation

with UnderTaxedProfitsRule
with LatePaymentInterest

val values = findValues
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val values = findValues
val values: IndexedSeq[EtmpSubtransactionRef] = findValues

.retrieveFinancialData(pillar2Id, fromDate, toDate)
.map(parseFinancialDataResponse)

/** Checks if there has been a recent payment */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** Checks if there has been a recent payment */

As mentioned above, we should try to not be using "any" comments unless they explain the 'why', not the 'what'. :)

case class RequiredValueMissing(field: String) extends IgnoredEtmpTransaction(s"$field was missing")
case class UnrelatedValue(field: String, value: String) extends IgnoredEtmpTransaction(s"$field has invalid value $value")
case class DidNotPassFilter[A](field: String, value: A, reason: String)
extends IgnoredEtmpTransaction(s"$field's value $value did not meet critera $reason")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
extends IgnoredEtmpTransaction(s"$field's value $value did not meet critera $reason")
extends IgnoredEtmpTransaction(s"$field's value $value did not meet criteria $reason")

Minor typo

financialData.payments
.flatMap(_.paymentItems.latestClearingDate)
.exists { latestClearing =>
val daysAgoLatestPaymentCleared = ChronoUnit.DAYS.between(latestClearing, LocalDate.now(clock))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val daysAgoLatestPaymentCleared = ChronoUnit.DAYS.between(latestClearing, LocalDate.now(clock))
val daysAgoLatestPaymentCleared: Long = ChronoUnit.DAYS.between(latestClearing, LocalDate.now(clock))

subTransaction = Some("6233"),
taxPeriodFrom = Some(today.minusMonths(1)),
taxPeriodTo = Some(today),
outstandingAmount = Some(BigDecimal(100)), // scalastyle:ignore magic.number
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of adding the scalastyle comment, can you modify the scalastyle-config.xml file?

<check level="warning" class="org.scalastyle.scalariform.MagicNumberChecker" enabled="true">
  <parameters>
    <parameter name="ignore"><![CDATA[-1,0,1,2,3,4,5,6,7,8,9,10,100,1000]]></parameter>
  </parameters>
</check>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, I'd like to ignore the magic number rule only for tests; I think it's a sensible rule for production code but it's a bit of a pain when adding examples. I haven't used scalastyle before, but I'll look into it.

Comment on lines 147 to 159
if (financialData.financialTransactions.isEmpty) {
None
} else {
val totalOutstandingAmount = financialData.getTotalOutstandingAmount
val totalOutstandingAmount = financialData.totalOutstandingAmount
val hasOutstandingPayment = financialData.hasOverdueOutstandingPayments(currentDate)
val hasRecentPayment = financialData.hasRecentPayment(currentDate = currentDate)
val hasRecentPayment = financialDataService.hasRecentPayment(financialData)

(hasOutstandingPayment, hasRecentPayment) match {
case (true, _) => Some(Outstanding(totalOutstandingAmount))
case (false, true) if totalOutstandingAmount == 0 => Some(Paid)
case _ => None
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to hold off on this for now, if that's OK. The next PR introduces a PaymentState model which is then used to determine both the notification banner and the payment tag, so this method is significantly tidied up there anyway.

Comment on lines +37 to +38
/** Calculates the total outstanding amount from financial data */
def totalOutstandingAmount: BigDecimal = outstandingCharges.map(_.outstandingAmount).sum
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I'll revisit these method names and get rid of the comments where they seem redundant.

outstandingAmount: Option[BigDecimal],
items: Seq[FinancialItem]
)
case class TaxPeriod(from: LocalDate, to: LocalDate)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I'll get something into the backlog to review the model, reduce any redundancy, etc. I'll highlight this but keep the scope fairly wide.


package models

import enumeratum.values.{StringEnum, StringEnumEntry}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. These are a bit unwieldy, and there's a clear dividing line to split along, so I'll get that done.

object EtmpSubtransactionRef extends StringEnum[EtmpSubtransactionRef] {
case object Dtt extends EtmpSubtransactionRef("6233") with DomesticTopupTax
case object Mtt extends EtmpSubtransactionRef("6234") with MultinationalTopupTax
case object UktrIirIrrMttUtprDiscDet
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find a source on all of the acronyms I pulled from the previous file 😅 That's why there isn't a trait for IRR, and I'm not sure what DiscDet is so I kept the names identical.

subTransaction = Some("6233"),
taxPeriodFrom = Some(today.minusMonths(1)),
taxPeriodTo = Some(today),
outstandingAmount = Some(BigDecimal(100)), // scalastyle:ignore magic.number
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, I'd like to ignore the magic number rule only for tests; I think it's a sensible rule for production code but it's a bit of a pain when adding examples. I haven't used scalastyle before, but I'll look into it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants