-
Notifications
You must be signed in to change notification settings - Fork 0
PIL-2449 Introduce domain model for financial data #563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
outstandingAmount: Option[BigDecimal], | ||
items: Seq[FinancialItem] | ||
) | ||
case class TaxPeriod(from: LocalDate, to: LocalDate) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
case EtmpMainTransactionRef.UkTaxReturnMain => (UktrMainOutstandingCharge.apply _).tupled(fields) | ||
case EtmpMainTransactionRef.LatePaymentInterest => (LatePaymentInterestOutstandingCharge.apply _).tupled(fields) | ||
case EtmpMainTransactionRef.RepaymentInterest => (RepaymentInterestOutstandingCharge.apply _).tupled(fields) |
There was a problem hiding this comment.
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 | ||
} | ||
} |
There was a problem hiding this comment.
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] | ||
} | ||
} |
There was a problem hiding this comment.
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))) |
There was a problem hiding this comment.
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)( |
There was a problem hiding this comment.
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.
object TaxPeriod { | ||
implicit val ordering: Ordering[TaxPeriod] = Ordering.by(_.from) | ||
} |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
outstandingCharges | |
financialData.outstandingCharges |
.toSeq | ||
|
||
FinancialSummary(AccountingPeriod(periodFrom, periodTo), transactionSummaries.sortBy(_.dueDate).reverse) | ||
FinancialSummary(AccountingPeriod(taxPeriod.from, taxPeriod.to), transactionSummaries.sortBy(_.dueDate).reverse) |
There was a problem hiding this comment.
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
:
.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) |
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 | ||
} | ||
} |
There was a problem hiding this comment.
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:
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
val values = findValues | |
val values: IndexedSeq[AccountStatus] = findValues |
Adding type annotation
with UnderTaxedProfitsRule | ||
with LatePaymentInterest | ||
|
||
val values = findValues |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
val values = findValues | |
val values: IndexedSeq[EtmpSubtransactionRef] = findValues |
.retrieveFinancialData(pillar2Id, fromDate, toDate) | ||
.map(parseFinancialDataResponse) | ||
|
||
/** Checks if there has been a recent payment */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/** 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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
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 | ||
} | ||
} |
There was a problem hiding this comment.
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.
/** Calculates the total outstanding amount from financial data */ | ||
def totalOutstandingAmount: BigDecimal = outstandingCharges.map(_.outstandingAmount).sum |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
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.
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.