Skip to content

Commit 04d36ca

Browse files
authored
Merge pull request #6550 from espoon-voltti/income-difference-bug-fix
Korjaus tulotietovertailusta syntyviin turhiin luonnoksiin
2 parents e4b6061 + e0f6ee8 commit 04d36ca

File tree

9 files changed

+255
-60
lines changed

9 files changed

+255
-60
lines changed

service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/service/VoucherValueDecisionGeneratorIntegrationTest.kt

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import fi.espoo.evaka.insertServiceNeedOptionVoucherValues
1212
import fi.espoo.evaka.insertServiceNeedOptions
1313
import fi.espoo.evaka.invoicing.calculateMonthlyAmount
1414
import fi.espoo.evaka.invoicing.controller.VoucherValueDecisionController
15+
import fi.espoo.evaka.invoicing.data.getHeadOfFamilyVoucherValueDecisions
1516
import fi.espoo.evaka.invoicing.domain.FeeAlterationType
1617
import fi.espoo.evaka.invoicing.domain.FeeThresholds
1718
import fi.espoo.evaka.invoicing.domain.IncomeCoefficient
@@ -86,7 +87,7 @@ class VoucherValueDecisionGeneratorIntegrationTest : FullApplicationTest(resetDb
8687
@Autowired
8788
private lateinit var coefficientMultiplierProvider: IncomeCoefficientMultiplierProvider
8889

89-
private val employee = DevEmployee()
90+
private val employee = DevEmployee(roles = setOf(UserRole.ADMIN))
9091
private val clock = MockEvakaClock(2021, 1, 1, 15, 0)
9192

9293
@BeforeEach
@@ -845,6 +846,107 @@ class VoucherValueDecisionGeneratorIntegrationTest : FullApplicationTest(resetDb
845846
)
846847
}
847848

849+
@Test
850+
fun `overlapping 2025-03 does not cause income difference or splitting`() {
851+
val period = FiniteDateRange(LocalDate.of(2025, 1, 1), LocalDate.of(2025, 12, 31))
852+
val clock = MockEvakaClock(HelsinkiDateTime.of(period.start, LocalTime.MIN))
853+
insertFamilyRelations(testAdult_1.id, listOf(testChild_1.id), period)
854+
insertIncome(testAdult_1.id, 10000, period.asDateRange())
855+
856+
val subPeriod1 = period.copy(end = LocalDate.of(2025, 6, 30))
857+
insertPlacement(testChild_1.id, subPeriod1, PlacementType.DAYCARE, testVoucherDaycare.id)
858+
859+
db.transaction { tx ->
860+
generator.generateNewDecisionsForAdult(tx, testAdult_1.id)
861+
862+
// old code did not store income id
863+
tx.execute {
864+
sql(
865+
"""
866+
UPDATE voucher_value_decision SET head_of_family_income = head_of_family_income - 'id'
867+
"""
868+
)
869+
}
870+
}
871+
voucherValueDecisionController.sendVoucherValueDecisionDrafts(
872+
dbInstance(),
873+
employee.user,
874+
clock,
875+
db.read { it.getHeadOfFamilyVoucherValueDecisions(testAdult_1.id) }.map { it.id },
876+
null,
877+
)
878+
879+
val subPeriod2 = period.copy(start = LocalDate.of(2025, 7, 1))
880+
insertPlacement(
881+
testChild_1.id,
882+
subPeriod2,
883+
PlacementType.DAYCARE_PART_TIME,
884+
testVoucherDaycare.id,
885+
)
886+
887+
db.transaction { tx -> generator.generateNewDecisionsForAdult(tx, testAdult_1.id) }
888+
889+
assertThat(getAllVoucherValueDecisions())
890+
.extracting({ FiniteDateRange(it.validFrom, it.validTo) }, { it.difference })
891+
.containsExactlyInAnyOrder(
892+
Tuple(subPeriod1, emptySet<VoucherValueDecisionDifference>()),
893+
Tuple(
894+
subPeriod2,
895+
setOf(
896+
VoucherValueDecisionDifference.PLACEMENT,
897+
VoucherValueDecisionDifference.VOUCHER_VALUE,
898+
VoucherValueDecisionDifference.SERVICE_NEED,
899+
),
900+
),
901+
)
902+
}
903+
904+
@Test
905+
fun `income starting 2025-03 does not cause unnecessary draft`() {
906+
val period = FiniteDateRange(LocalDate.of(2025, 3, 1), LocalDate.of(2025, 12, 31))
907+
val clock = MockEvakaClock(HelsinkiDateTime.of(period.start, LocalTime.MIN))
908+
insertFamilyRelations(testAdult_1.id, listOf(testChild_1.id), period)
909+
insertIncome(testAdult_1.id, 10000, period.asDateRange())
910+
insertPlacement(testChild_1.id, period, PlacementType.DAYCARE, testVoucherDaycare.id)
911+
912+
db.transaction { tx ->
913+
generator.generateNewDecisionsForAdult(tx, testAdult_1.id)
914+
915+
// old code did not store income id
916+
tx.execute {
917+
sql(
918+
"""
919+
UPDATE voucher_value_decision SET head_of_family_income = head_of_family_income - 'id'
920+
"""
921+
)
922+
}
923+
}
924+
voucherValueDecisionController.sendVoucherValueDecisionDrafts(
925+
dbInstance(),
926+
employee.user,
927+
clock,
928+
db.read { it.getHeadOfFamilyVoucherValueDecisions(testAdult_1.id) }.map { it.id },
929+
null,
930+
)
931+
asyncJobRunner.runPendingJobsSync(clock)
932+
933+
db.transaction { tx -> generator.generateNewDecisionsForAdult(tx, testAdult_1.id) }
934+
935+
assertThat(getAllVoucherValueDecisions())
936+
.extracting(
937+
{ FiniteDateRange(it.validFrom, it.validTo) },
938+
{ it.status },
939+
{ it.difference },
940+
)
941+
.containsExactly(
942+
Tuple(
943+
period,
944+
VoucherValueDecisionStatus.SENT,
945+
emptySet<VoucherValueDecisionDifference>(),
946+
)
947+
)
948+
}
949+
848950
@Test
849951
fun `child income difference`() {
850952
val period = FiniteDateRange(LocalDate.of(2022, 1, 1), LocalDate.of(2022, 12, 31))

service/src/main/kotlin/fi/espoo/evaka/invoicing/domain/FeeDecisions.kt

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,12 @@ data class FeeDecision(
5454

5555
override fun withCreated(created: HelsinkiDateTime) = this.copy(created = created)
5656

57-
override fun contentEquals(decision: FeeDecision): Boolean =
58-
FeeDecisionDifference.getDifference(this, decision).isEmpty()
57+
override fun contentEquals(
58+
decision: FeeDecision,
59+
nrOfDaysDecisionCanBeSentInAdvance: Long,
60+
): Boolean =
61+
FeeDecisionDifference.getDifference(this, decision, nrOfDaysDecisionCanBeSentInAdvance)
62+
.isEmpty()
5963

6064
override fun overlapsWith(other: FeeDecision): Boolean {
6165
return this.validDuring.overlaps(other.validDuring) &&
@@ -135,17 +139,23 @@ enum class FeeDecisionType : DatabaseEnum {
135139
}
136140

137141
@ConstList("feeDecisionDifferences")
138-
enum class FeeDecisionDifference(val contentEquals: (d1: FeeDecision, d2: FeeDecision) -> Boolean) :
139-
DatabaseEnum {
140-
GUARDIANS({ d1, d2 ->
142+
enum class FeeDecisionDifference(
143+
val contentEquals:
144+
(d1: FeeDecision, d2: FeeDecision, nrOfDaysFeeDecisionCanBeSentInAdvance: Long) -> Boolean
145+
) : DatabaseEnum {
146+
GUARDIANS({ d1, d2, _ ->
141147
setOf(d1.headOfFamilyId, d1.partnerId) == setOf(d2.headOfFamilyId, d2.partnerId)
142148
}),
143-
CHILDREN({ d1, d2 ->
149+
CHILDREN({ d1, d2, _ ->
144150
d1.children.map { it.child.id }.toSet() == d2.children.map { it.child.id }.toSet()
145151
}),
146-
INCOME({ d1, d2 ->
152+
INCOME({ d1, d2, nrOfDaysFeeDecisionCanBeSentInAdvance ->
147153
val logic =
148-
if (d2.validFrom < LocalDate.of(2025, 3, 1)) IncomeComparisonVersion.V1
154+
if (
155+
d2.validFrom <
156+
LocalDate.of(2025, 3, 1).plusDays(nrOfDaysFeeDecisionCanBeSentInAdvance)
157+
)
158+
IncomeComparisonVersion.V1
149159
else IncomeComparisonVersion.V2
150160
setOf(
151161
d1.headOfFamilyIncome?.effectiveComparable(logic),
@@ -156,23 +166,31 @@ enum class FeeDecisionDifference(val contentEquals: (d1: FeeDecision, d2: FeeDec
156166
d2.partnerIncome?.effectiveComparable(logic),
157167
) && decisionChildrenEquals(d1, d2) { it.childIncome?.effectiveComparable(logic) }
158168
}),
159-
PLACEMENT({ d1, d2 -> decisionChildrenEquals(d1, d2) { it.placement } }),
160-
SERVICE_NEED({ d1, d2 ->
169+
PLACEMENT({ d1, d2, _ -> decisionChildrenEquals(d1, d2) { it.placement } }),
170+
SERVICE_NEED({ d1, d2, _ ->
161171
decisionChildrenEquals(d1, d2) { it.serviceNeed.copy(optionId = null) }
162172
}),
163-
SIBLING_DISCOUNT({ d1, d2 -> decisionChildrenEquals(d1, d2) { it.siblingDiscount } }),
164-
FEE_ALTERATIONS({ d1, d2 -> decisionChildrenEquals(d1, d2) { it.feeAlterations } }),
165-
FAMILY_SIZE({ d1, d2 -> d1.familySize == d2.familySize }),
166-
FEE_THRESHOLDS({ d1, d2 -> d1.feeThresholds == d2.feeThresholds });
173+
SIBLING_DISCOUNT({ d1, d2, _ -> decisionChildrenEquals(d1, d2) { it.siblingDiscount } }),
174+
FEE_ALTERATIONS({ d1, d2, _ -> decisionChildrenEquals(d1, d2) { it.feeAlterations } }),
175+
FAMILY_SIZE({ d1, d2, _ -> d1.familySize == d2.familySize }),
176+
FEE_THRESHOLDS({ d1, d2, _ -> d1.feeThresholds == d2.feeThresholds });
167177

168178
override val sqlType: String = "fee_decision_difference"
169179

170180
companion object {
171-
fun getDifference(d1: FeeDecision, d2: FeeDecision): Set<FeeDecisionDifference> {
181+
fun getDifference(
182+
d1: FeeDecision,
183+
d2: FeeDecision,
184+
nrOfDaysFeeDecisionCanBeSentInAdvance: Long,
185+
): Set<FeeDecisionDifference> {
172186
if (d1.isEmpty() && d2.isEmpty()) {
173-
return if (GUARDIANS.contentEquals(d1, d2)) emptySet() else setOf(GUARDIANS)
187+
return if (GUARDIANS.contentEquals(d1, d2, nrOfDaysFeeDecisionCanBeSentInAdvance))
188+
emptySet()
189+
else setOf(GUARDIANS)
174190
}
175-
return values().filterNot { it.contentEquals(d1, d2) }.toSet()
191+
return values()
192+
.filterNot { it.contentEquals(d1, d2, nrOfDaysFeeDecisionCanBeSentInAdvance) }
193+
.toSet()
176194
}
177195
}
178196
}

service/src/main/kotlin/fi/espoo/evaka/invoicing/domain/FinanceDecisions.kt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface FinanceDecision<Decision : FinanceDecision<Decision>> {
2626

2727
fun withCreated(created: HelsinkiDateTime): Decision
2828

29-
fun contentEquals(decision: Decision): Boolean
29+
fun contentEquals(decision: Decision, nrOfDaysDecisionCanBeSentInAdvance: Long): Boolean
3030

3131
fun overlapsWith(other: Decision): Boolean
3232

@@ -37,11 +37,6 @@ interface FinanceDecision<Decision : FinanceDecision<Decision>> {
3737
fun annul(): Decision
3838
}
3939

40-
fun <Decision : FinanceDecision<Decision>> decisionContentsAreEqual(
41-
decision1: Decision,
42-
decision2: Decision,
43-
): Boolean = decision1.contentEquals(decision2)
44-
4540
fun <Decision : FinanceDecision<Decision>> updateEndDatesOrAnnulConflictingDecisions(
4641
newDecisions: List<Decision>,
4742
conflicting: List<Decision>,

service/src/main/kotlin/fi/espoo/evaka/invoicing/domain/VoucherValueDecisions.kt

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,16 @@ data class VoucherValueDecision(
6666

6767
override fun withCreated(created: HelsinkiDateTime) = this.copy(created = created)
6868

69-
override fun contentEquals(decision: VoucherValueDecision): Boolean =
70-
VoucherValueDecisionDifference.getDifference(this, decision).isEmpty()
69+
override fun contentEquals(
70+
decision: VoucherValueDecision,
71+
nrOfDaysDecisionCanBeSentInAdvance: Long,
72+
): Boolean =
73+
VoucherValueDecisionDifference.getDifference(
74+
this,
75+
decision,
76+
nrOfDaysDecisionCanBeSentInAdvance,
77+
)
78+
.isEmpty()
7179

7280
override fun overlapsWith(other: VoucherValueDecision): Boolean {
7381
return this.child.id == other.child.id &&
@@ -157,14 +165,24 @@ enum class VoucherValueDecisionStatus : DatabaseEnum {
157165

158166
@ConstList("voucherValueDecisionDifferences")
159167
enum class VoucherValueDecisionDifference(
160-
val contentEquals: (d1: VoucherValueDecision, d2: VoucherValueDecision) -> Boolean
168+
val contentEquals:
169+
(
170+
d1: VoucherValueDecision,
171+
d2: VoucherValueDecision,
172+
nrOfDaysVoucherValueDecisionCanBeSentInAdvance: Long,
173+
) -> Boolean
161174
) : DatabaseEnum {
162-
GUARDIANS({ d1, d2 ->
175+
GUARDIANS({ d1, d2, _ ->
163176
setOf(d1.headOfFamilyId, d1.partnerId) == setOf(d2.headOfFamilyId, d2.partnerId)
164177
}),
165-
INCOME({ d1, d2 ->
178+
INCOME({ d1, d2, nrOfDaysVoucherValueDecisionCanBeSentInAdvance ->
166179
val logic =
167-
if (d2.validFrom < LocalDate.of(2025, 3, 1)) IncomeComparisonVersion.V1
180+
if (
181+
d2.validFrom <
182+
LocalDate.of(2025, 3, 1)
183+
.plusDays(nrOfDaysVoucherValueDecisionCanBeSentInAdvance)
184+
)
185+
IncomeComparisonVersion.V1
168186
else IncomeComparisonVersion.V2
169187
setOf(
170188
d1.headOfFamilyIncome?.effectiveComparable(logic),
@@ -176,32 +194,41 @@ enum class VoucherValueDecisionDifference(
176194
) &&
177195
d1.childIncome?.effectiveComparable(logic) == d2.childIncome?.effectiveComparable(logic)
178196
}),
179-
FAMILY_SIZE({ d1, d2 -> d1.familySize == d2.familySize }),
180-
PLACEMENT({ d1, d2 -> d1.placement == d2.placement }),
181-
SERVICE_NEED({ d1, d2 -> d1.serviceNeed == d2.serviceNeed }),
182-
SIBLING_DISCOUNT({ d1, d2 -> d1.siblingDiscount == d2.siblingDiscount }),
183-
CO_PAYMENT({ d1, d2 -> d1.coPayment == d2.coPayment }),
184-
FEE_ALTERATIONS({ d1, d2 -> d1.feeAlterations == d2.feeAlterations }),
185-
FINAL_CO_PAYMENT({ d1, d2 -> d1.finalCoPayment == d2.finalCoPayment }),
186-
BASE_VALUE({ d1, d2 -> d1.baseValue == d2.baseValue }),
187-
VOUCHER_VALUE({ d1, d2 ->
197+
FAMILY_SIZE({ d1, d2, _ -> d1.familySize == d2.familySize }),
198+
PLACEMENT({ d1, d2, _ -> d1.placement == d2.placement }),
199+
SERVICE_NEED({ d1, d2, _ -> d1.serviceNeed == d2.serviceNeed }),
200+
SIBLING_DISCOUNT({ d1, d2, _ -> d1.siblingDiscount == d2.siblingDiscount }),
201+
CO_PAYMENT({ d1, d2, _ -> d1.coPayment == d2.coPayment }),
202+
FEE_ALTERATIONS({ d1, d2, _ -> d1.feeAlterations == d2.feeAlterations }),
203+
FINAL_CO_PAYMENT({ d1, d2, _ -> d1.finalCoPayment == d2.finalCoPayment }),
204+
BASE_VALUE({ d1, d2, _ -> d1.baseValue == d2.baseValue }),
205+
VOUCHER_VALUE({ d1, d2, _ ->
188206
// Voucher value rounding was added later, so the values need to be rounded before comparing
189207
// them to consider old decisions as not changed
190208
roundToEuros(BigDecimal(d1.voucherValue)) == roundToEuros(BigDecimal(d2.voucherValue))
191209
}),
192-
FEE_THRESHOLDS({ d1, d2 -> d1.feeThresholds == d2.feeThresholds });
210+
FEE_THRESHOLDS({ d1, d2, _ -> d1.feeThresholds == d2.feeThresholds });
193211

194212
override val sqlType: String = "voucher_value_decision_difference"
195213

196214
companion object {
197215
fun getDifference(
198216
d1: VoucherValueDecision,
199217
d2: VoucherValueDecision,
218+
nrOfDaysVoucherValueDecisionCanBeSentInAdvance: Long,
200219
): Set<VoucherValueDecisionDifference> {
201220
if (d1.isEmpty() && d2.isEmpty()) {
202-
return if (GUARDIANS.contentEquals(d1, d2)) emptySet() else setOf(GUARDIANS)
221+
return if (
222+
GUARDIANS.contentEquals(d1, d2, nrOfDaysVoucherValueDecisionCanBeSentInAdvance)
223+
)
224+
emptySet()
225+
else setOf(GUARDIANS)
203226
}
204-
return values().filterNot { it.contentEquals(d1, d2) }.toSet()
227+
return values()
228+
.filterNot {
229+
it.contentEquals(d1, d2, nrOfDaysVoucherValueDecisionCanBeSentInAdvance)
230+
}
231+
.toSet()
205232
}
206233
}
207234
}

service/src/main/kotlin/fi/espoo/evaka/invoicing/service/FinanceDecisionGenerator.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class FinanceDecisionGenerator(
2626
private val coefficientMultiplierProvider: IncomeCoefficientMultiplierProvider,
2727
env: EvakaEnv,
2828
private val featureConfig: FeatureConfig,
29+
private val evakaEnv: EvakaEnv,
2930
) {
3031
private val feeDecisionMinDate = env.feeDecisionMinDate
3132

@@ -83,6 +84,7 @@ FROM ids
8384
coefficientMultiplierProvider = coefficientMultiplierProvider,
8485
financeMinDate = feeDecisionMinDate,
8586
headOfFamilyId = headOfFamily,
87+
nrOfDaysFeeDecisionCanBeSentInAdvance = evakaEnv.nrOfDaysFeeDecisionCanBeSentInAdvance,
8688
retroactiveOverride = from,
8789
)
8890
}
@@ -101,6 +103,8 @@ FROM ids
101103
valueDecisionCapacityFactorEnabled =
102104
featureConfig.valueDecisionCapacityFactorEnabled,
103105
childId = childId,
106+
nrOfDaysVoucherValueDecisionCanBeSentInAdvance =
107+
evakaEnv.nrOfDaysVoucherValueDecisionCanBeSentInAdvance,
104108
retroactiveOverride = from,
105109
)
106110
}
@@ -124,6 +128,8 @@ FROM ids
124128
coefficientMultiplierProvider = coefficientMultiplierProvider,
125129
financeMinDate = feeDecisionMinDate,
126130
headOfFamilyId = adult,
131+
nrOfDaysFeeDecisionCanBeSentInAdvance =
132+
evakaEnv.nrOfDaysFeeDecisionCanBeSentInAdvance,
127133
)
128134
}
129135

@@ -136,6 +142,8 @@ FROM ids
136142
valueDecisionCapacityFactorEnabled =
137143
featureConfig.valueDecisionCapacityFactorEnabled,
138144
childId = childId,
145+
nrOfDaysVoucherValueDecisionCanBeSentInAdvance =
146+
evakaEnv.nrOfDaysVoucherValueDecisionCanBeSentInAdvance,
139147
)
140148
}
141149
}
@@ -148,6 +156,8 @@ FROM ids
148156
coefficientMultiplierProvider = coefficientMultiplierProvider,
149157
financeMinDate = feeDecisionMinDate,
150158
headOfFamilyId = adultId,
159+
nrOfDaysFeeDecisionCanBeSentInAdvance =
160+
evakaEnv.nrOfDaysFeeDecisionCanBeSentInAdvance,
151161
)
152162
}
153163

@@ -158,6 +168,8 @@ FROM ids
158168
financeMinDate = feeDecisionMinDate,
159169
valueDecisionCapacityFactorEnabled = featureConfig.valueDecisionCapacityFactorEnabled,
160170
childId = childId,
171+
nrOfDaysVoucherValueDecisionCanBeSentInAdvance =
172+
evakaEnv.nrOfDaysVoucherValueDecisionCanBeSentInAdvance,
161173
)
162174
}
163175
}

0 commit comments

Comments
 (0)