Skip to content

Commit

Permalink
Merge pull request #6032 from espoon-voltti/replacement-invoices-part4
Browse files Browse the repository at this point in the history
Oikaisulaskut, osa 4: Oikaisulaskujen muodostus yksittäiselle päämiehelle (ei vielä käytössä)
  • Loading branch information
akheron authored Nov 27, 2024
2 parents c1cb5d6 + 6024ce0 commit 0d15667
Show file tree
Hide file tree
Showing 15 changed files with 320 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,30 @@
// SPDX-License-Identifier: LGPL-2.1-or-later

import orderBy from 'lodash/orderBy'
import React, { useState } from 'react'
import React, { useContext, useState } from 'react'
import { Link } from 'react-router-dom'

import { wrapResult } from 'lib-common/api'
import { PersonContext } from 'employee-frontend/state/person'
import { UserContext } from 'employee-frontend/state/user'
import { formatCents } from 'lib-common/money'
import { useQueryResult } from 'lib-common/query'
import { UUID } from 'lib-common/types'
import { useApiState } from 'lib-common/utils/useRestApi'
import { MutateButton } from 'lib-components/atoms/buttons/MutateButton'
import { CollapsibleContentArea } from 'lib-components/layout/Container'
import { Table, Tbody, Td, Th, Thead, Tr } from 'lib-components/layout/Table'
import { FixedSpaceColumn } from 'lib-components/layout/flex-helpers'
import { H2 } from 'lib-components/typography'
import { faRefresh } from 'lib-icons'

import { getHeadOfFamilyInvoices } from '../../generated/api-clients/invoicing'
import { useTranslation } from '../../state/i18n'
import { StatusTd } from '../PersonProfile'
import { renderResult } from '../async-rendering'
import { formatInvoicePeriod } from '../invoice/utils'

const getHeadOfFamilyInvoicesResult = wrapResult(getHeadOfFamilyInvoices)
import {
createReplacementDraftsForHeadOfFamilyMutation,
headOfFamilyInvoicesQuery
} from './queries'

interface Props {
id: UUID
Expand All @@ -32,11 +38,10 @@ export default React.memo(function PersonInvoices({
open: startOpen
}: Props) {
const { i18n } = useTranslation()
const user = useContext(UserContext)
const { permittedActions } = useContext(PersonContext)
const [open, setOpen] = useState(startOpen)
const [invoices] = useApiState(
() => getHeadOfFamilyInvoicesResult({ id }),
[id]
)
const invoices = useQueryResult(headOfFamilyInvoicesQuery({ id }))

return (
<div>
Expand All @@ -48,6 +53,18 @@ export default React.memo(function PersonInvoices({
paddingVertical="L"
data-qa="person-invoices-collapsible"
>
{user?.user?.accessibleFeatures.replacementInvoices &&
permittedActions.has('CREATE_REPLACEMENT_DRAFT_INVOICES') ? (
<FixedSpaceColumn alignItems="flex-end">
<MutateButton
icon={faRefresh}
appearance="inline"
mutation={createReplacementDraftsForHeadOfFamilyMutation}
onClick={() => ({ headOfFamilyId: id })}
text={i18n.personProfile.invoice.createReplacementDrafts}
/>
</FixedSpaceColumn>
) : null}
{renderResult(invoices, (invoices) => (
<Table data-qa="table-of-invoices">
<Thead>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { Arg0, UUID } from 'lib-common/types'

import {
createInvoiceCorrection,
createReplacementDraftsForHeadOfFamily,
deleteInvoiceCorrection,
getHeadOfFamilyInvoices,
getIncomeMultipliers,
getPersonInvoiceCorrections,
updateInvoiceCorrectionNote
Expand All @@ -19,7 +21,8 @@ const queryKeys = createQueryKeys('personProfile', {
invoiceCorrections: (args: Arg0<typeof getPersonInvoiceCorrections>) => [
'invoiceCorrections',
args
]
],
headOfFamilyInvoices: (args: { id: UUID }) => ['headOfFamilyInvoices', args]
})

export const incomeCoefficientMultipliersQuery = query({
Expand Down Expand Up @@ -54,3 +57,15 @@ export const deleteInvoiceCorrectionMutation = mutation({
queryKeys.invoiceCorrections({ personId: args.personId })
]
})

export const headOfFamilyInvoicesQuery = query({
api: getHeadOfFamilyInvoices,
queryKey: queryKeys.headOfFamilyInvoices
})

export const createReplacementDraftsForHeadOfFamilyMutation = mutation({
api: createReplacementDraftsForHeadOfFamily,
invalidateQueryKeys: (arg) => [
queryKeys.headOfFamilyInvoices({ id: arg.headOfFamilyId })
]
})
16 changes: 16 additions & 0 deletions frontend/src/employee-frontend/generated/api-clients/invoicing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,22 @@ export async function createDraftInvoices(): Promise<void> {
}


/**
* Generated from fi.espoo.evaka.invoicing.controller.InvoiceController.createReplacementDraftsForHeadOfFamily
*/
export async function createReplacementDraftsForHeadOfFamily(
request: {
headOfFamilyId: UUID
}
): Promise<void> {
const { data: json } = await client.request<JsonOf<void>>({
url: uri`/employee/invoices/create-replacement-drafts/${request.headOfFamilyId}`.toString(),
method: 'POST'
})
return json
}


/**
* Generated from fi.espoo.evaka.invoicing.controller.InvoiceController.deleteDraftInvoices
*/
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib-common/generated/action.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ export type Person =
| 'CREATE_INVOICE_CORRECTION'
| 'CREATE_PARENTSHIP'
| 'CREATE_PARTNERSHIP'
| 'CREATE_REPLACEMENT_DRAFT_INVOICES'
| 'DELETE'
| 'DISABLE_SSN_ADDING'
| 'DOWNLOAD_ADDRESS_PAGE'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1949,6 +1949,7 @@ export const fi = {
handled: 'Tuloselvitys käsitelty'
},
invoice: {
createReplacementDrafts: 'Muodosta oikaisulaskut',
validity: 'Kausi',
price: 'Summa',
status: 'Tila'
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib-icons/fontawesome.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ declare module 'Icons' {
const faPlusCircle: IconDefinition
const faPrint: IconDefinition
const faRedo: IconDefinition
const faRefresh: IconDefinition
const faQuestion: IconDefinition
const faQuestionCircle: IconDefinition
const faSearch: IconDefinition
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib-icons/free-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export { faPrint } from '@fortawesome/free-solid-svg-icons/faPrint'
export { faQuestion } from '@fortawesome/free-solid-svg-icons/faQuestion'
export { faQuestionCircle } from '@fortawesome/free-solid-svg-icons/faQuestionCircle'
export { faRedo } from '@fortawesome/free-solid-svg-icons/faRedo'
export { faRefresh } from '@fortawesome/free-solid-svg-icons/faRefresh'
export { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch'
export { faSignInAlt as faSignIn } from '@fortawesome/free-solid-svg-icons/faSignInAlt'
export { faSignOutAlt as faSignOut } from '@fortawesome/free-solid-svg-icons/faSignOutAlt'
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib-icons/pro-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export { faPrint } from '@fortawesome/pro-light-svg-icons/faPrint'
export { faQuestion } from '@fortawesome/pro-light-svg-icons/faQuestion'
export { faQuestionCircle } from '@fortawesome/pro-light-svg-icons/faQuestionCircle'
export { faRedo } from '@fortawesome/pro-light-svg-icons/faRedo'
export { faRefresh } from '@fortawesome/pro-light-svg-icons/faRefresh'
export { faSearch } from '@fortawesome/pro-light-svg-icons/faSearch'
export { faSignIn } from '@fortawesome/pro-light-svg-icons/faSignIn'
export { faSignOut } from '@fortawesome/pro-light-svg-icons/faSignOut'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import fi.espoo.evaka.invoicing.domain.FeeDecisionStatus
import fi.espoo.evaka.invoicing.domain.InvoiceDetailed
import fi.espoo.evaka.invoicing.domain.InvoiceReplacementReason
import fi.espoo.evaka.invoicing.domain.InvoiceStatus
import fi.espoo.evaka.shared.PersonId
import fi.espoo.evaka.shared.auth.AuthenticatedUser
import fi.espoo.evaka.shared.auth.UserRole
import fi.espoo.evaka.shared.dev.DevAbsence
Expand Down Expand Up @@ -80,7 +81,7 @@ class ReplacementInvoicesIntegrationTest : FullApplicationTest(resetDbBeforeEach
fun `replacement draft invoice is created when a force majeure absence is added retroactively`() {
insertPlacementAndFeeDecision(fee = 29500)

val original = generateTestInvoice()
val original = generateAndSendInvoices().single()
assertEquals(InvoiceStatus.SENT, original.status)
assertEquals(29500, original.totalPrice)

Expand Down Expand Up @@ -119,7 +120,7 @@ class ReplacementInvoicesIntegrationTest : FullApplicationTest(resetDbBeforeEach
@Test
fun `invoice is not replaced if total price doesn't change`() {
insertPlacementAndFeeDecision()
generateTestInvoice()
generateAndSendInvoices()

db.transaction { tx ->
tx.insert(
Expand All @@ -139,7 +140,7 @@ class ReplacementInvoicesIntegrationTest : FullApplicationTest(resetDbBeforeEach
@Test
fun `zero-priced replacement is created`() {
insertPlacementAndFeeDecision()
val original = generateTestInvoice()
val original = generateAndSendInvoices().single()

// Sick leave for whole month => no fee
db.transaction { tx ->
Expand Down Expand Up @@ -215,12 +216,85 @@ class ReplacementInvoicesIntegrationTest : FullApplicationTest(resetDbBeforeEach
assertEquals(replacements[5].periodEnd, endMonth.atEndOfMonth())
}

@Test
fun `replacement invoice are generated for a multiple heads of family`() {
val headOfFamily2 = DevPerson(ssn = "010101-9998")
val child2 = DevPerson()
db.transaction { tx ->
tx.insert(headOfFamily2, DevPersonType.ADULT)
tx.insert(child2, DevPersonType.CHILD)
}

insertPlacementAndFeeDecision(headOfFamily, child)
insertPlacementAndFeeDecision(headOfFamily2, child2)

val original = generateAndSendInvoices()

db.transaction { tx ->
listOf(child.id, child2.id).forEach { childId ->
tx.insert(
DevAbsence(
childId = childId,
date = previousMonth.atDay(1),
absenceCategory = AbsenceCategory.BILLABLE,
absenceType = AbsenceType.FORCE_MAJEURE,
)
)
}
}

val replacement = generateReplacementDrafts()

assertEquals(2, original.size)
assertTrue(original.any { it.headOfFamily.id == headOfFamily.id })
assertTrue(original.any { it.headOfFamily.id == headOfFamily2.id })
assertEquals(2, replacement.size)
assertTrue(replacement.any { it.headOfFamily.id == headOfFamily.id })
assertTrue(replacement.any { it.headOfFamily.id == headOfFamily2.id })
}

@Test
fun `replacement invoice are generated for an individual head of family`() {
val headOfFamily2 = DevPerson(ssn = "010101-9998")
val child2 = DevPerson()
db.transaction { tx ->
tx.insert(headOfFamily2, DevPersonType.ADULT)
tx.insert(child2, DevPersonType.CHILD)
}

insertPlacementAndFeeDecision(headOfFamily, child)
insertPlacementAndFeeDecision(headOfFamily2, child2)

val original = generateAndSendInvoices()

db.transaction { tx ->
listOf(child.id, child2.id).forEach { childId ->
tx.insert(
DevAbsence(
childId = childId,
date = previousMonth.atDay(1),
absenceCategory = AbsenceCategory.BILLABLE,
absenceType = AbsenceType.FORCE_MAJEURE,
)
)
}
}

val replacement = generateReplacementDraftsForHeadOfFamily(headOfFamily.id)

assertEquals(2, original.size)
assertTrue(original.any { it.headOfFamily.id == headOfFamily.id })
assertTrue(original.any { it.headOfFamily.id == headOfFamily2.id })
assertEquals(1, replacement.size)
assertEquals(headOfFamily.id, replacement.single().headOfFamily.id)
}

@Test
fun `replacement invoice can be marked as sent`() {
val employee = DevEmployee(roles = setOf(UserRole.FINANCE_ADMIN))

insertPlacementAndFeeDecision()
val original = generateTestInvoice()
val original = generateAndSendInvoices().single()

db.transaction { tx ->
tx.insert(employee)
Expand Down Expand Up @@ -286,7 +360,16 @@ class ReplacementInvoicesIntegrationTest : FullApplicationTest(resetDbBeforeEach
return db.read { tx -> tx.searchInvoices(InvoiceStatus.REPLACEMENT_DRAFT) }
}

private fun generateReplacementDraftsForHeadOfFamily(
headOfFamilyId: PersonId
): List<InvoiceDetailed> {
invoiceGenerator.generateReplacementDraftInvoicesForHeadOfFamily(db, today, headOfFamilyId)
return db.read { tx -> tx.searchInvoices(InvoiceStatus.REPLACEMENT_DRAFT) }
}

private fun insertPlacementAndFeeDecision(
headOfFamily: DevPerson = this.headOfFamily,
child: DevPerson = this.child,
fee: Int = 29500,
range: FiniteDateRange = FiniteDateRange.ofMonth(previousMonth),
) {
Expand Down Expand Up @@ -323,7 +406,7 @@ class ReplacementInvoicesIntegrationTest : FullApplicationTest(resetDbBeforeEach
}
}

fun generateTestInvoice(): InvoiceDetailed =
fun generateAndSendInvoices(): List<InvoiceDetailed> =
db.transaction { tx ->
invoiceGenerator.generateAllDraftInvoices(tx, previousMonth)
invoiceService.sendInvoices(
Expand All @@ -335,6 +418,6 @@ class ReplacementInvoicesIntegrationTest : FullApplicationTest(resetDbBeforeEach
null,
)

tx.searchInvoices().single()
tx.searchInvoices()
}
}
1 change: 1 addition & 0 deletions service/src/main/kotlin/fi/espoo/evaka/Audit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ enum class Audit(
InvoiceCorrectionsNoteUpdate,
InvoiceCorrectionsRead,
InvoicesCreate,
InvoicesCreateReplacementDrafts,
InvoicesDeleteDrafts,
InvoicesMarkSent,
InvoicesMarkReplacementDraftSent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,32 @@ class InvoiceController(
val permittedActions: Set<Action.Invoice>,
)

@PostMapping("/create-replacement-drafts/{headOfFamilyId}")
fun createReplacementDraftsForHeadOfFamily(
db: Database,
user: AuthenticatedUser.Employee,
clock: EvakaClock,
@PathVariable headOfFamilyId: PersonId,
) {
db.connect { dbc ->
dbc.read {
accessControl.requirePermissionFor(
it,
user,
clock,
Action.Person.CREATE_REPLACEMENT_DRAFT_INVOICES,
headOfFamilyId,
)
}
generator.generateReplacementDraftInvoicesForHeadOfFamily(
dbc,
clock.today(),
headOfFamilyId,
)
}
.also { Audit.InvoicesCreateReplacementDrafts.log(targetId = AuditId(headOfFamilyId)) }
}

data class MarkReplacementDraftSentRequest(
val reason: InvoiceReplacementReason,
val notes: String,
Expand Down
Loading

0 comments on commit 0d15667

Please sign in to comment.