diff --git a/frontend/src/citizen-frontend/generated/api-clients/incomestatement.ts b/frontend/src/citizen-frontend/generated/api-clients/incomestatement.ts index eed07dad1ae..349df1959a6 100644 --- a/frontend/src/citizen-frontend/generated/api-clients/incomestatement.ts +++ b/frontend/src/citizen-frontend/generated/api-clients/incomestatement.ts @@ -25,12 +25,17 @@ import { uri } from 'lib-common/uri' export async function createChildIncomeStatement( request: { childId: UUID, + draft?: boolean | null, body: IncomeStatementBody } ): Promise { + const params = createUrlSearchParams( + ['draft', request.draft?.toString()] + ) const { data: json } = await client.request>({ url: uri`/citizen/income-statements/child/${request.childId}`.toString(), method: 'POST', + params, data: request.body satisfies JsonCompatible }) return json @@ -42,12 +47,17 @@ export async function createChildIncomeStatement( */ export async function createIncomeStatement( request: { + draft?: boolean | null, body: IncomeStatementBody } ): Promise { + const params = createUrlSearchParams( + ['draft', request.draft?.toString()] + ) const { data: json } = await client.request>({ url: uri`/citizen/income-statements`.toString(), method: 'POST', + params, data: request.body satisfies JsonCompatible }) return json @@ -208,12 +218,17 @@ export async function updateChildIncomeStatement( request: { childId: UUID, incomeStatementId: UUID, + draft?: boolean | null, body: IncomeStatementBody } ): Promise { + const params = createUrlSearchParams( + ['draft', request.draft?.toString()] + ) const { data: json } = await client.request>({ url: uri`/citizen/income-statements/child/${request.childId}/${request.incomeStatementId}`.toString(), method: 'PUT', + params, data: request.body satisfies JsonCompatible }) return json @@ -226,12 +241,17 @@ export async function updateChildIncomeStatement( export async function updateIncomeStatement( request: { incomeStatementId: UUID, + draft?: boolean | null, body: IncomeStatementBody } ): Promise { + const params = createUrlSearchParams( + ['draft', request.draft?.toString()] + ) const { data: json } = await client.request>({ url: uri`/citizen/income-statements/${request.incomeStatementId}`.toString(), method: 'PUT', + params, data: request.body satisfies JsonCompatible }) return json diff --git a/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementEditor.tsx b/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementEditor.tsx index dc0fea6fc91..8f8ea1b7fad 100644 --- a/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementEditor.tsx +++ b/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementEditor.tsx @@ -2,10 +2,11 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { combine, Loading, Result } from 'lib-common/api' +import { IncomeStatementStatus } from 'lib-common/generated/api-types/incomestatement' import LocalDate from 'lib-common/local-date' import { constantQuery, @@ -32,6 +33,7 @@ import { emptyIncomeStatementForm } from './types/form' interface EditorState { id: string | undefined + status: IncomeStatementStatus startDates: LocalDate[] formData: Form.IncomeStatementForm } @@ -52,6 +54,7 @@ function useInitialEditorState( return combine(incomeStatement, startDates).map( ([incomeStatement, startDates]) => ({ id, + status: incomeStatement?.status ?? 'DRAFT', startDates, formData: incomeStatement === null @@ -106,42 +109,56 @@ export default React.memo(function ChildIncomeStatementEditor() { updateChildIncomeStatementMutation ) - return renderResult(state, (state) => { - const { id, formData, startDates } = state - - const save = () => { - const validatedData = formData ? fromBody('child', formData) : undefined - if (validatedData) { - if (id) { - return updateChildIncomeStatement({ - childId, - incomeStatementId: id, - body: validatedData - }) + const draftBody = useMemo( + () => state.map((state) => fromBody('child', state.formData, true)), + [state] + ) + + const validatedBody = useMemo( + () => state.map((state) => fromBody('child', state.formData, false)), + [state] + ) + + return renderResult( + combine(state, draftBody, validatedBody), + ([{ status, formData, startDates }, draftBody, validatedBody]) => { + const save = (draft: boolean) => { + const body = draft ? draftBody : validatedBody + if (body) { + if (incomeStatementId) { + return updateChildIncomeStatement({ + childId, + incomeStatementId, + body, + draft + }) + } else { + return createChildIncomeStatement({ childId, body, draft }) + } } else { - return createChildIncomeStatement({ childId, body: validatedData }) + setShowFormErrors(true) + if (form.current) form.current.scrollToErrors() + return } - } else { - setShowFormErrors(true) - if (form.current) form.current.scrollToErrors() - return } - } - return ( -
- -
- ) - }) + return ( +
+ +
+ ) + } + ) }) diff --git a/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementForm.tsx b/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementForm.tsx index 9e6e7fadfad..df3eec0d22c 100644 --- a/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementForm.tsx +++ b/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementForm.tsx @@ -7,6 +7,7 @@ import styled from 'styled-components' import { Result } from 'lib-common/api' import { Attachment } from 'lib-common/api-types/attachment' +import { IncomeStatementStatus } from 'lib-common/generated/api-types/incomestatement' import LocalDate from 'lib-common/local-date' import { UUID } from 'lib-common/types' import { scrollToRef } from 'lib-common/utils/scrolling' @@ -40,11 +41,13 @@ import * as Form from './types/form' interface Props { incomeStatementId: UUID | undefined + status: IncomeStatementStatus formData: Form.IncomeStatementForm showFormErrors: boolean otherStartDates: LocalDate[] + draftSaveEnabled: boolean onChange: SetStateCallback - onSave: () => Promise> | undefined + onSave: (draft: boolean) => Promise> | undefined onSuccess: () => void onCancel: () => void } @@ -215,9 +218,11 @@ export default React.memo( React.forwardRef(function ChildIncomeStatementForm( { incomeStatementId, + status, formData, showFormErrors, otherStartDates, + draftSaveEnabled, onChange, onSave, onSuccess, @@ -256,7 +261,7 @@ export default React.memo( } })) - const saveButtonEnabled = formData.attachments.length > 0 && formData.assure + const sendButtonEnabled = formData.attachments.length > 0 && formData.assure return ( <> @@ -300,11 +305,19 @@ export default React.memo( + {status === 'DRAFT' && draftSaveEnabled && ( + onSave(true)} + onSuccess={onSuccess} + data-qa="save-draft-btn" + /> + )} onSave(false)} + disabled={!sendButtonEnabled} onSuccess={onSuccess} data-qa="save-btn" /> diff --git a/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementView.tsx b/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementView.tsx index 4ba361fd33e..6e3fb620a9f 100644 --- a/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementView.tsx +++ b/frontend/src/citizen-frontend/income-statements/ChildIncomeStatementView.tsx @@ -55,7 +55,7 @@ export default React.memo(function ChildIncomeStatementView() {

{t.income.view.title}

- {!incomeStatement.handled && ( + {incomeStatement.status !== 'HANDLED' && ( {t.income.table.incomeStatementForm} {t.income.table.createdAt} + {t.income.table.sentAt} @@ -93,10 +94,15 @@ const ChildIncomeStatementsTable = React.memo( {item.startDate.format()} - {item.endDate?.format()} - {item.created.toLocalDate().format()} + {item.createdAt.toLocalDate().format()} + + {item.sentAt + ? item.sentAt.toLocalDate().format() + : t.income.table.notSent} + - {item.handled ? ( + {item.status === 'HANDLED' ? ( {t.income.table.handled} ) : ( <> diff --git a/frontend/src/citizen-frontend/income-statements/IncomeStatementComponents.tsx b/frontend/src/citizen-frontend/income-statements/IncomeStatementComponents.tsx index 57f17a64f43..cf1d14b9d5e 100644 --- a/frontend/src/citizen-frontend/income-statements/IncomeStatementComponents.tsx +++ b/frontend/src/citizen-frontend/income-statements/IncomeStatementComponents.tsx @@ -52,7 +52,8 @@ export const ActionContainer = styled.div` display: flex; flex-direction: column; justify-content: flex-end; - padding: ${defaultMargins.m} 0; + padding: ${defaultMargins.s} 0; + background-color: ${(p) => p.theme.colors.grayscale.g0}; > * { margin: 0 ${defaultMargins.m}; diff --git a/frontend/src/citizen-frontend/income-statements/IncomeStatementEditor.tsx b/frontend/src/citizen-frontend/income-statements/IncomeStatementEditor.tsx index d28596d8fc8..3387bc13c88 100644 --- a/frontend/src/citizen-frontend/income-statements/IncomeStatementEditor.tsx +++ b/frontend/src/citizen-frontend/income-statements/IncomeStatementEditor.tsx @@ -1,11 +1,12 @@ -// SPDX-FileCopyrightText: 2017-2022 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { combine, Loading, Result } from 'lib-common/api' +import { IncomeStatementStatus } from 'lib-common/generated/api-types/incomestatement' import LocalDate from 'lib-common/local-date' import { constantQuery, @@ -32,6 +33,7 @@ import { emptyIncomeStatementForm } from './types/form' interface EditorState { id: string | undefined + status: IncomeStatementStatus startDates: LocalDate[] formData: Form.IncomeStatementForm } @@ -45,6 +47,7 @@ function useInitialEditorState(id: UUID | undefined): Result { return combine(incomeStatement, startDates).map( ([incomeStatement, startDates]) => ({ id, + status: incomeStatement?.status ?? 'DRAFT', startDates, formData: incomeStatement === null @@ -93,41 +96,51 @@ export default React.memo(function IncomeStatementEditor() { updateIncomeStatementMutation ) - return renderResult(state, (state) => { - const { id, formData, startDates } = state - - const save = () => { - const validatedData = formData ? fromBody('adult', formData) : undefined - if (validatedData) { - if (id) { - return updateIncomeStatement({ - incomeStatementId: id, - body: validatedData - }) + const draftBody = useMemo( + () => state.map((state) => fromBody('adult', state.formData, true)), + [state] + ) + + const validatedBody = useMemo( + () => state.map((state) => fromBody('adult', state.formData, false)), + [state] + ) + + return renderResult( + combine(state, draftBody, validatedBody), + ([{ status, formData, startDates }, draftBody, validatedBody]) => { + const save = (draft: boolean) => { + const body = draft ? draftBody : validatedBody + if (body) { + if (incomeStatementId) { + return updateIncomeStatement({ incomeStatementId, body, draft }) + } else { + return createIncomeStatement({ body, draft }) + } } else { - return createIncomeStatement({ body: validatedData }) + setShowFormErrors(true) + if (form.current) form.current.scrollToErrors() + return } - } else { - setShowFormErrors(true) - if (form.current) form.current.scrollToErrors() - return } - } - return ( -
- -
- ) - }) + return ( +
+ +
+ ) + } + ) }) diff --git a/frontend/src/citizen-frontend/income-statements/IncomeStatementForm.tsx b/frontend/src/citizen-frontend/income-statements/IncomeStatementForm.tsx index bab6bf905c4..4ed7ff14e20 100644 --- a/frontend/src/citizen-frontend/income-statements/IncomeStatementForm.tsx +++ b/frontend/src/citizen-frontend/income-statements/IncomeStatementForm.tsx @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2017-2022 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later @@ -14,6 +14,7 @@ import { validInt } from 'lib-common/form-validation' import { + IncomeStatementStatus, OtherIncome, otherIncomes } from 'lib-common/generated/api-types/incomestatement' @@ -61,11 +62,13 @@ import * as Form from './types/form' interface Props { incomeStatementId: UUID | undefined + status: IncomeStatementStatus formData: Form.IncomeStatementForm showFormErrors: boolean otherStartDates: LocalDate[] + draftSaveEnabled: boolean onChange: SetStateCallback - onSave: () => Promise> | undefined + onSave: (draft: boolean) => Promise> | undefined onSuccess: () => void onCancel: () => void } @@ -74,9 +77,11 @@ export default React.memo( React.forwardRef(function IncomeStatementForm( { incomeStatementId, + status, formData, showFormErrors, otherStartDates, + draftSaveEnabled, onChange, onSave, onSuccess, @@ -176,7 +181,7 @@ export default React.memo( [onChange] ) - const saveButtonEnabled = useMemo( + const sendButtonEnabled = useMemo( () => formData.startDate && (formData.highestFee || @@ -243,6 +248,7 @@ export default React.memo( /> )} + + {status === 'DRAFT' && draftSaveEnabled && ( + onSave(true)} + onSuccess={onSuccess} + data-qa="save-draft-btn" + /> + )} onSave(false)} + disabled={!sendButtonEnabled} onSuccess={onSuccess} /> diff --git a/frontend/src/citizen-frontend/income-statements/IncomeStatementView.tsx b/frontend/src/citizen-frontend/income-statements/IncomeStatementView.tsx index db1e4881fd3..b4c8feda9ce 100644 --- a/frontend/src/citizen-frontend/income-statements/IncomeStatementView.tsx +++ b/frontend/src/citizen-frontend/income-statements/IncomeStatementView.tsx @@ -56,7 +56,7 @@ export default React.memo(function IncomeStatementView() {

{t.income.view.title}

- {!incomeStatement.handled && ( + {incomeStatement.status !== 'HANDLED' && ( {t.income.table.incomeStatementForm} {t.income.table.createdAt} + {t.income.table.sentAt} @@ -80,10 +81,15 @@ const IncomeStatementsTable = React.memo(function IncomeStatementsTable({ {item.startDate.format()} - {item.endDate?.format()} - {item.created.toLocalDate().format()} + {item.createdAt.toLocalDate().format()} + + {item.sentAt + ? item.sentAt.toLocalDate().format() + : t.income.table.notSent} + - {item.handled ? ( + {item.status === 'HANDLED' ? ( {t.income.table.handled} ) : ( <> @@ -92,6 +98,7 @@ const IncomeStatementsTable = React.memo(function IncomeStatementsTable({ text={t.common.edit} onClick={onEdit(item.id)} altText={t.common.edit} + data-qa="edit-income-statement-btn" /> formData.endDate) return null + if (!draft && formData.endDate && startDate > formData.endDate) return null if (formData.highestFee) { if (personType === 'child') { @@ -50,21 +57,22 @@ export function fromBody( } return { type: 'HIGHEST_FEE', startDate, endDate: formData.endDate } } else { - if (personType === 'adult' && !formData.endDate) return null + if (!draft && personType === 'adult' && !formData.endDate) return null } if (formData.childIncome) { - return { + const childIncome: ChildIncomeBody = { type: 'CHILD_INCOME', startDate, endDate: formData.endDate, otherInfo: formData.otherInfo, attachmentIds: formData.attachments.map((a) => a.id) - } as ChildIncomeBody + } + return childIncome } const gross = validateGross(formData.gross) - const entrepreneur = validateEntrepreneur(formData.entrepreneur) + const entrepreneur = validateEntrepreneur(formData.entrepreneur, draft) if ( gross === invalid || @@ -106,7 +114,7 @@ function validateGross(formData: Form.Gross) { } } -function validateEntrepreneur(formData: Form.Entrepreneur) { +function validateEntrepreneur(formData: Form.Entrepreneur, draft: boolean) { if (!formData.selected) return null const { @@ -135,7 +143,7 @@ function validateEntrepreneur(formData: Form.Entrepreneur) { const accountant = limitedCompany || selfEmployed || partnership - ? validateAccountant(formData.accountant) + ? validateAccountant(formData.accountant, draft) : null if (accountant === invalid) { return invalid @@ -201,13 +209,13 @@ function validateLimitedCompany(formData: Form.LimitedCompany) { return { incomeSource: formData.incomeSource } } -function validateAccountant(accountant: Form.Accountant) { +function validateAccountant(accountant: Form.Accountant, draft: boolean) { const result = { name: accountant.name.trim(), address: accountant.address.trim(), phone: accountant.phone.trim(), email: accountant.email.trim() } - if (!result.name || !result.phone || !result.email) return invalid + if (!draft && (!result.name || !result.phone || !result.email)) return invalid return result } diff --git a/frontend/src/e2e-test/dev-api/fixtures.ts b/frontend/src/e2e-test/dev-api/fixtures.ts index 8532c582a5a..1f83875b523 100644 --- a/frontend/src/e2e-test/dev-api/fixtures.ts +++ b/frontend/src/e2e-test/dev-api/fixtures.ts @@ -83,6 +83,7 @@ import { createHolidayQuestionnaire, createIncome, createIncomeNotification, + createIncomeStatement, createInvoices, createOtherAssistanceMeasures, createParentships, @@ -128,6 +129,7 @@ import { DevEmployeePin, DevFridgeChild, DevIncome, + DevIncomeStatement, DevInvoice, DevInvoiceRow, DevParentship, @@ -812,6 +814,28 @@ export class Fixture { }) } + static incomeStatement( + initial: SemiPartial + ): IncomeStatementBuilder { + return new IncomeStatementBuilder({ + id: uuidv4(), + createdAt: HelsinkiDateTime.now(), + modifiedAt: HelsinkiDateTime.now(), + createdBy: systemInternalUser.id, + modifiedBy: systemInternalUser.id, + data: { + type: 'HIGHEST_FEE', + startDate: LocalDate.todayInSystemTz(), + endDate: null + }, + status: 'SENT', + sentAt: HelsinkiDateTime.now(), + handledAt: null, + handlerId: null, + ...initial + }) + } + static incomeNotification( initial: SemiPartial ): IncomeNotificationBuilder { @@ -1621,6 +1645,13 @@ export class IncomeBuilder extends FixtureBuilder { } } +export class IncomeStatementBuilder extends FixtureBuilder { + async save() { + await createIncomeStatement({ body: this.data }) + return this.data + } +} + export class IncomeNotificationBuilder extends FixtureBuilder { async save() { await createIncomeNotification({ body: this.data }) diff --git a/frontend/src/e2e-test/generated/api-clients.ts b/frontend/src/e2e-test/generated/api-clients.ts index e2376afff28..04ba65c7164 100644 --- a/frontend/src/e2e-test/generated/api-clients.ts +++ b/frontend/src/e2e-test/generated/api-clients.ts @@ -36,7 +36,6 @@ import { DevChild } from './api-types' import { DevChildAttendance } from './api-types' import { DevChildDocument } from './api-types' import { DevClubTerm } from './api-types' -import { DevCreateIncomeStatements } from './api-types' import { DevDailyServiceTimeNotification } from './api-types' import { DevDailyServiceTimes } from './api-types' import { DevDaycare } from './api-types' @@ -53,6 +52,7 @@ import { DevFridgeChild } from './api-types' import { DevFridgePartner } from './api-types' import { DevGuardian } from './api-types' import { DevIncome } from './api-types' +import { DevIncomeStatement } from './api-types' import { DevInvoice } from './api-types' import { DevMobileDevice } from './api-types' import { DevOtherAssistanceMeasure } from './api-types' @@ -1128,18 +1128,18 @@ export async function createIncomeNotification( /** -* Generated from fi.espoo.evaka.shared.dev.DevApi.createIncomeStatements +* Generated from fi.espoo.evaka.shared.dev.DevApi.createIncomeStatement */ -export async function createIncomeStatements( +export async function createIncomeStatement( request: { - body: DevCreateIncomeStatements + body: DevIncomeStatement } ): Promise { try { const { data: json } = await devClient.request>({ - url: uri`/income-statements`.toString(), + url: uri`/income-statement`.toString(), method: 'POST', - data: request.body satisfies JsonCompatible + data: request.body satisfies JsonCompatible }) return json } catch (e) { diff --git a/frontend/src/e2e-test/generated/api-types.ts b/frontend/src/e2e-test/generated/api-types.ts index 89ffdf95a4e..16eb42f8723 100644 --- a/frontend/src/e2e-test/generated/api-types.ts +++ b/frontend/src/e2e-test/generated/api-types.ts @@ -41,6 +41,7 @@ import { FeeAlterationWithEffect } from 'lib-common/generated/api-types/invoicin import { FeeDecisionThresholds } from 'lib-common/generated/api-types/invoicing' import { IncomeEffect } from 'lib-common/generated/api-types/invoicing' import { IncomeStatementBody } from 'lib-common/generated/api-types/incomestatement' +import { IncomeStatementStatus } from 'lib-common/generated/api-types/incomestatement' import { IncomeValue } from 'lib-common/generated/api-types/invoicing' import { InvoiceStatus } from 'lib-common/generated/api-types/invoicing' import { JsonOf } from 'lib-common/json' @@ -380,14 +381,6 @@ export interface DevClubTerm { termBreaks: FiniteDateRange[] } -/** -* Generated from fi.espoo.evaka.shared.dev.DevApi.DevCreateIncomeStatements -*/ -export interface DevCreateIncomeStatements { - data: IncomeStatementBody[] - personId: UUID -} - /** * Generated from fi.espoo.evaka.shared.dev.DevDailyServiceTimeNotification */ @@ -627,6 +620,23 @@ export interface DevIncome { worksAtEcha: boolean } +/** +* Generated from fi.espoo.evaka.shared.dev.DevIncomeStatement +*/ +export interface DevIncomeStatement { + createdAt: HelsinkiDateTime + createdBy: UUID + data: IncomeStatementBody + handledAt: HelsinkiDateTime | null + handlerId: UUID | null + id: UUID + modifiedAt: HelsinkiDateTime + modifiedBy: UUID + personId: UUID + sentAt: HelsinkiDateTime | null + status: IncomeStatementStatus +} + /** * Generated from fi.espoo.evaka.shared.dev.DevInvoice */ @@ -1231,14 +1241,6 @@ export function deserializeJsonDevClubTerm(json: JsonOf): DevClubTe } -export function deserializeJsonDevCreateIncomeStatements(json: JsonOf): DevCreateIncomeStatements { - return { - ...json, - data: json.data.map(e => deserializeJsonIncomeStatementBody(e)) - } -} - - export function deserializeJsonDevDailyServiceTimes(json: JsonOf): DevDailyServiceTimes { return { ...json, @@ -1366,6 +1368,18 @@ export function deserializeJsonDevIncome(json: JsonOf): DevIncome { } +export function deserializeJsonDevIncomeStatement(json: JsonOf): DevIncomeStatement { + return { + ...json, + createdAt: HelsinkiDateTime.parseIso(json.createdAt), + data: deserializeJsonIncomeStatementBody(json.data), + handledAt: (json.handledAt != null) ? HelsinkiDateTime.parseIso(json.handledAt) : null, + modifiedAt: HelsinkiDateTime.parseIso(json.modifiedAt), + sentAt: (json.sentAt != null) ? HelsinkiDateTime.parseIso(json.sentAt) : null + } +} + + export function deserializeJsonDevInvoice(json: JsonOf): DevInvoice { return { ...json, diff --git a/frontend/src/e2e-test/pages/citizen/citizen-child-income.ts b/frontend/src/e2e-test/pages/citizen/citizen-child-income.ts index 6527e6dcf29..50754e45d18 100644 --- a/frontend/src/e2e-test/pages/citizen/citizen-child-income.ts +++ b/frontend/src/e2e-test/pages/citizen/citizen-child-income.ts @@ -50,11 +50,13 @@ export class CitizenChildIncomeStatementEditPage { otherInfoInput: TextInput assure: Checkbox saveButton: Element + saveDraftButton: Element constructor(private readonly page: Page) { this.startDateInput = new TextInput(page.findByDataQa('start-date')) this.otherInfoInput = new TextInput(page.findByDataQa('other-info')) this.assure = new Checkbox(page.findByDataQa('assure-checkbox')) this.saveButton = page.findByDataQa('save-btn') + this.saveDraftButton = page.findByDataQa('save-draft-btn') } async waitUntilReady() { @@ -74,6 +76,10 @@ export class CitizenChildIncomeStatementEditPage { await this.assure.check() } + async saveDraft() { + await this.saveDraftButton.click() + } + async save() { await this.saveButton.click() } diff --git a/frontend/src/e2e-test/pages/citizen/citizen-income.ts b/frontend/src/e2e-test/pages/citizen/citizen-income.ts index 60f67079a8e..7878ed6c119 100644 --- a/frontend/src/e2e-test/pages/citizen/citizen-income.ts +++ b/frontend/src/e2e-test/pages/citizen/citizen-income.ts @@ -39,6 +39,10 @@ export default class CitizenIncomePage { await this.page.findByDataQa('new-income-statement-btn').click() } + async editIncomeStatement(n: number) { + await this.page.findAllByDataQa('edit-income-statement-btn').nth(n).click() + } + async selectIncomeStatementType( type: 'highest-fee' | 'gross-income' | 'entrepreneur-income' ) { @@ -56,6 +60,10 @@ export default class CitizenIncomePage { await this.page.findByDataQa('title').click() } + async saveDraft() { + await this.page.findByDataQa('save-draft-btn').click() + } + async submit() { await this.page.find('button.primary').click() } diff --git a/frontend/src/e2e-test/specs/0_citizen/citizen-child-income-statement.spec.ts b/frontend/src/e2e-test/specs/0_citizen/citizen-child-income-statement.spec.ts index 8d7aaf15b87..af285623821 100644 --- a/frontend/src/e2e-test/specs/0_citizen/citizen-child-income-statement.spec.ts +++ b/frontend/src/e2e-test/specs/0_citizen/citizen-child-income-statement.spec.ts @@ -104,4 +104,23 @@ describe('Child Income statements', () => { await child1ISList.deleteChildIncomeStatement(0) await child1ISList.assertIncomeStatementMissingWarningIsShown() }) + + test('Save a highest fee income statement as draft, then update and send', async () => { + // Create + await header.selectTab('income') + await child1ISList.assertChildCount(1) + + const editPage = await child1ISList.createIncomeStatement() + await editPage.setValidFromDate('01.02.2034') + await editPage.typeOtherInfo('foo bar baz') + await editPage.saveDraft() + await child1ISList.assertChildIncomeStatementRowCount(1) + + // Edit and sent + await child1ISList.clickEditChildIncomeStatement(0) + await editPage.uploadAttachment(testFilePath1) + await editPage.selectAssure() + await editPage.save() + await child1ISList.assertChildIncomeStatementRowCount(1) + }) }) diff --git a/frontend/src/e2e-test/specs/0_citizen/citizen-income-statement.spec.ts b/frontend/src/e2e-test/specs/0_citizen/citizen-income-statement.spec.ts index 67b242cb456..a58e744aa0e 100644 --- a/frontend/src/e2e-test/specs/0_citizen/citizen-income-statement.spec.ts +++ b/frontend/src/e2e-test/specs/0_citizen/citizen-income-statement.spec.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -import LocalDate from 'lib-common/local-date' +import HelsinkiDateTime from 'lib-common/helsinki-date-time' import { testAdult, Fixture } from '../../dev-api/fixtures' import { resetServiceState } from '../../generated/api-clients' @@ -16,22 +16,29 @@ let page: Page let header: CitizenHeader let incomeStatementsPage: IncomeStatementsPage +const now = HelsinkiDateTime.of(2024, 11, 25, 12) + beforeEach(async () => { await resetServiceState() await Fixture.person(testAdult).saveAdult({ updateMockVtjWithDependants: [] }) - page = await Page.open() + page = await Page.open({ mockedTime: now }) await enduserLogin(page, testAdult) header = new CitizenHeader(page) incomeStatementsPage = new IncomeStatementsPage(page) }) -async function assertIncomeStatementCreated(startDate: string) { +async function assertIncomeStatementCreated( + startDate: string, + sent: HelsinkiDateTime | null +) { await waitUntilEqual(async () => await incomeStatementsPage.rows.count(), 1) - await incomeStatementsPage.rows - .only() - .assertText((text) => text.includes(startDate)) + const row = incomeStatementsPage.rows.only() + await row.assertText((text) => text.includes(startDate)) + await row.assertText((text) => + text.includes(sent ? sent.toLocalDate().format() : 'Ei lähetetty') + ) } const assertRequiredAttachment = async (attachment: string, present = true) => @@ -59,7 +66,7 @@ describe('Income statements', () => { await incomeStatementsPage.checkAssured() await incomeStatementsPage.submit() - await assertIncomeStatementCreated(startDate) + await assertIncomeStatementCreated(startDate, now) }) test('Gross income', async () => { @@ -69,10 +76,7 @@ describe('Income statements', () => { // Start date can be max 1y from now so an error is shown await incomeStatementsPage.setValidFromDate( - LocalDate.todayInHelsinkiTz() - .subMonths(12) - .subDays(1) - .format('d.M.yyyy') + now.toLocalDate().subMonths(12).subDays(1).format('d.M.yyyy') ) await incomeStatementsPage.incomeStartDateInfo.waitUntilVisible() @@ -94,7 +98,7 @@ describe('Income statements', () => { await incomeStatementsPage.incomeEndDateInfo.waitUntilHidden() await incomeStatementsPage.incomeValidMaxRangeInfo.waitUntilHidden() await incomeStatementsPage.submit() - await assertIncomeStatementCreated(startDate) + await assertIncomeStatementCreated(startDate, now) }) }) @@ -109,7 +113,7 @@ describe('Income statements', () => { ) await incomeStatementsPage.selectEntrepreneurType('full-time') await incomeStatementsPage.setEntrepreneurStartDate( - LocalDate.todayInSystemTz().addYears(-10).format() + now.toLocalDate().addYears(-10).format() ) await incomeStatementsPage.selectEntrepreneurSpouse('no') @@ -136,7 +140,7 @@ describe('Income statements', () => { await incomeStatementsPage.checkAssured() await incomeStatementsPage.submit() - await assertIncomeStatementCreated(startDate) + await assertIncomeStatementCreated(startDate, now) }) test('Self employed', async () => { await header.selectTab('income') @@ -148,7 +152,7 @@ describe('Income statements', () => { ) await incomeStatementsPage.selectEntrepreneurType('part-time') await incomeStatementsPage.setEntrepreneurStartDate( - LocalDate.todayInSystemTz().addYears(-5).addWeeks(-7).format() + now.toLocalDate().addYears(-5).addWeeks(-7).format() ) await incomeStatementsPage.selectEntrepreneurSpouse('no') @@ -173,7 +177,7 @@ describe('Income statements', () => { await incomeStatementsPage.checkAssured() await incomeStatementsPage.submit() - await assertIncomeStatementCreated(startDate) + await assertIncomeStatementCreated(startDate, now) }) test('Light entrepreneur', async () => { @@ -186,7 +190,7 @@ describe('Income statements', () => { ) await incomeStatementsPage.selectEntrepreneurType('full-time') await incomeStatementsPage.setEntrepreneurStartDate( - LocalDate.todayInSystemTz().addMonths(-3).format() + now.toLocalDate().addMonths(-3).format() ) await incomeStatementsPage.selectEntrepreneurSpouse('no') @@ -207,7 +211,7 @@ describe('Income statements', () => { await incomeStatementsPage.checkAssured() await incomeStatementsPage.submit() - await assertIncomeStatementCreated(startDate) + await assertIncomeStatementCreated(startDate, now) }) test('Partnership', async () => { @@ -220,7 +224,7 @@ describe('Income statements', () => { ) await incomeStatementsPage.selectEntrepreneurType('full-time') await incomeStatementsPage.setEntrepreneurStartDate( - LocalDate.todayInSystemTz().addMonths(-1).addDays(3).format() + now.toLocalDate().addMonths(-1).addDays(3).format() ) await incomeStatementsPage.selectEntrepreneurSpouse('yes') @@ -234,7 +238,27 @@ describe('Income statements', () => { await incomeStatementsPage.checkAssured() await incomeStatementsPage.submit() - await assertIncomeStatementCreated(startDate) + await assertIncomeStatementCreated(startDate, now) + }) + }) + + describe('Saving as draft', () => { + test('No need to check assured', async () => { + await header.selectTab('income') + await incomeStatementsPage.createNewIncomeStatement() + await incomeStatementsPage.setValidFromDate(startDate) + await incomeStatementsPage.selectIncomeStatementType('highest-fee') + await incomeStatementsPage.saveDraft() + + await assertIncomeStatementCreated(startDate, null) + + const startDate2 = '24.12.2044' + await incomeStatementsPage.editIncomeStatement(0) + await incomeStatementsPage.setValidFromDate(startDate2) + await incomeStatementsPage.checkAssured() + await incomeStatementsPage.submit() + + await assertIncomeStatementCreated(startDate2, now) }) }) }) diff --git a/frontend/src/e2e-test/specs/4_finance/income-staments.spec.ts b/frontend/src/e2e-test/specs/4_finance/income-staments.spec.ts index 0bc0e7d9dc5..51858626149 100644 --- a/frontend/src/e2e-test/specs/4_finance/income-staments.spec.ts +++ b/frontend/src/e2e-test/specs/4_finance/income-staments.spec.ts @@ -16,7 +16,6 @@ import { } from '../../dev-api/fixtures' import { createDaycarePlacements, - createIncomeStatements, resetServiceState } from '../../generated/api-clients' import EmployeeNav from '../../pages/employee/employee-nav' @@ -59,23 +58,22 @@ async function navigateToIncomeStatements() { describe('Income statements', () => { test('Income statement can be set handled', async () => { - await createIncomeStatements({ - body: { - personId: testAdult.id, - data: [ - { - type: 'HIGHEST_FEE', - startDate: today.addYears(-1), - endDate: today.addDays(-1) - }, - { - type: 'HIGHEST_FEE', - startDate: today, - endDate: null - } - ] + await Fixture.incomeStatement({ + personId: testAdult.id, + data: { + type: 'HIGHEST_FEE', + startDate: today.addYears(-1), + endDate: today.addDays(-1) } - }) + }).save() + await Fixture.incomeStatement({ + personId: testAdult.id, + data: { + type: 'HIGHEST_FEE', + startDate: today, + endDate: null + } + }).save() let incomeStatementsPage = await navigateToIncomeStatements() await incomeStatementsPage.searchButton.click() @@ -130,18 +128,14 @@ describe('Income statements', () => { ] }) - await createIncomeStatements({ - body: { - personId: testAdult.id, - data: [ - { - type: 'HIGHEST_FEE', - startDate, - endDate - } - ] + await Fixture.incomeStatement({ + personId: testAdult.id, + data: { + type: 'HIGHEST_FEE', + startDate, + endDate } - }) + }).save() const incomeStatementsPage = await navigateToIncomeStatements() await incomeStatementsPage.searchButton.click() @@ -165,20 +159,16 @@ describe('Income statements', () => { }) test('Child income statement is listed on finance worker unhandled income statement list', async () => { - await createIncomeStatements({ - body: { - personId: testChild.id, - data: [ - { - type: 'CHILD_INCOME', - otherInfo: 'Test info', - startDate: today, - endDate: today, - attachmentIds: [] - } - ] + await Fixture.incomeStatement({ + personId: testChild.id, + data: { + type: 'CHILD_INCOME', + otherInfo: 'Test info', + startDate: today, + endDate: today, + attachmentIds: [] } - }) + }).save() const incomeStatementsPage = await navigateToIncomeStatements() await incomeStatementsPage.searchButton.click() diff --git a/frontend/src/e2e-test/specs/5_employee/child-income-statements.spec.ts b/frontend/src/e2e-test/specs/5_employee/child-income-statements.spec.ts index c695b15b681..9ed7bad9b17 100644 --- a/frontend/src/e2e-test/specs/5_employee/child-income-statements.spec.ts +++ b/frontend/src/e2e-test/specs/5_employee/child-income-statements.spec.ts @@ -16,7 +16,6 @@ import { } from '../../dev-api/fixtures' import { createDaycarePlacements, - createIncomeStatements, resetServiceState } from '../../generated/api-clients' import ChildInformationPage from '../../pages/employee/child-information' @@ -50,20 +49,16 @@ describe('Child profile income statements', () => { ) await createDaycarePlacements({ body: [daycarePlacementFixture] }) - await createIncomeStatements({ - body: { - personId: testChild.id, - data: [ - { - type: 'CHILD_INCOME', - otherInfo: 'Test info', - startDate: LocalDate.todayInSystemTz(), - endDate: LocalDate.todayInSystemTz(), - attachmentIds: [] - } - ] + await Fixture.incomeStatement({ + personId: testChild.id, + data: { + type: 'CHILD_INCOME', + otherInfo: 'Test info', + startDate: LocalDate.todayInSystemTz(), + endDate: LocalDate.todayInSystemTz(), + attachmentIds: [] } - }) + }).save() const profilePage = new ChildInformationPage(page) await profilePage.navigateToChild(testChild.id) diff --git a/frontend/src/e2e-test/specs/5_employee/guardian-income-statements.spec.ts b/frontend/src/e2e-test/specs/5_employee/guardian-income-statements.spec.ts index ffe24f71916..4b0517a0a4d 100644 --- a/frontend/src/e2e-test/specs/5_employee/guardian-income-statements.spec.ts +++ b/frontend/src/e2e-test/specs/5_employee/guardian-income-statements.spec.ts @@ -17,7 +17,6 @@ import { } from '../../dev-api/fixtures' import { createDaycarePlacements, - createIncomeStatements, insertGuardians, resetServiceState } from '../../generated/api-clients' @@ -68,20 +67,16 @@ describe('Guardian income statements', () => { ] }) - await createIncomeStatements({ - body: { - personId: child.id, - data: [ - { - type: 'CHILD_INCOME', - otherInfo: 'Test other info', - startDate: mockedNow.toLocalDate(), - endDate: mockedNow.toLocalDate(), - attachmentIds: [] - } - ] + await Fixture.incomeStatement({ + personId: child.id, + data: { + type: 'CHILD_INCOME', + otherInfo: 'Test other info', + startDate: mockedNow.toLocalDate(), + endDate: mockedNow.toLocalDate(), + attachmentIds: [] } - }) + }).save() await page.goto(config.employeeUrl + '/profile/' + personId) const guardianPage = new GuardianInformationPage(page) diff --git a/frontend/src/employee-frontend/components/IncomeStatementPage.tsx b/frontend/src/employee-frontend/components/IncomeStatementPage.tsx index e81a3581bcc..6b6b2139246 100644 --- a/frontend/src/employee-frontend/components/IncomeStatementPage.tsx +++ b/frontend/src/employee-frontend/components/IncomeStatementPage.tsx @@ -135,7 +135,7 @@ export default React.memo(function IncomeStatementPage() { onSave={onUpdateHandled} onSuccess={() => navigateToPersonProfile(incomeStatement)} initialValues={{ - handled: incomeStatement.handled, + handled: incomeStatement.status === 'HANDLED', handlerNote: incomeStatement.handlerNote }} /> diff --git a/frontend/src/employee-frontend/components/income-statements/IncomeStatementsPage.tsx b/frontend/src/employee-frontend/components/income-statements/IncomeStatementsPage.tsx index e8e2397ab8a..43b4090e9aa 100644 --- a/frontend/src/employee-frontend/components/income-statements/IncomeStatementsPage.tsx +++ b/frontend/src/employee-frontend/components/income-statements/IncomeStatementsPage.tsx @@ -80,10 +80,10 @@ function IncomeStatementsList({ {i18n.incomeStatement.table.area} - {i18n.incomeStatement.table.created} + {i18n.incomeStatement.table.sentAt} {row.primaryCareArea} - {row.created.toLocalDate().format()} + {row.sentAt.toLocalDate().format()} {row.startDate.format()} {row.incomeEndDate?.format() ?? '-'} @@ -164,7 +164,7 @@ export default React.memo(function IncomeStatementsPage() { const { i18n } = useTranslation() const [page, setPage] = useState(1) - const [sortBy, setSortBy] = useState('CREATED') + const [sortBy, setSortBy] = useState('SENT_AT') const [sortDirection, setSortDirection] = useState('ASC') const [searchParams, setSearchParams] = useState<{ areas: string[] | null diff --git a/frontend/src/employee-frontend/components/person-profile/IncomeStatementsTable.tsx b/frontend/src/employee-frontend/components/person-profile/IncomeStatementsTable.tsx index 958318b7d60..4153fd3214b 100644 --- a/frontend/src/employee-frontend/components/person-profile/IncomeStatementsTable.tsx +++ b/frontend/src/employee-frontend/components/person-profile/IncomeStatementsTable.tsx @@ -31,7 +31,7 @@ export default React.memo(function IncomeStatementsTable({ {i18n.incomeStatementHeading} - {i18n.createdHeading} + {i18n.sentAtHeading} {i18n.handledHeading} @@ -70,14 +70,14 @@ const IncomeStatementRow = React.memo(function IncomeStatementRow({ - {incomeStatement.created.toLocalDate().format()} + {incomeStatement.sentAt?.toLocalDate()?.format() ?? '-'} {!!incomeStatement.handlerNote && ( diff --git a/frontend/src/lib-common/generated/api-types/incomestatement.ts b/frontend/src/lib-common/generated/api-types/incomestatement.ts index 3c110cd7750..6212de9714c 100644 --- a/frontend/src/lib-common/generated/api-types/incomestatement.ts +++ b/frontend/src/lib-common/generated/api-types/incomestatement.ts @@ -90,17 +90,19 @@ export namespace IncomeStatement { export interface ChildIncome { type: 'CHILD_INCOME' attachments: Attachment[] - created: HelsinkiDateTime + createdAt: HelsinkiDateTime endDate: LocalDate | null firstName: string - handled: boolean + handledAt: HelsinkiDateTime | null handlerNote: string id: UUID lastName: string + modifiedAt: HelsinkiDateTime otherInfo: string personId: UUID + sentAt: HelsinkiDateTime | null startDate: LocalDate - updated: HelsinkiDateTime + status: IncomeStatementStatus } /** @@ -108,16 +110,18 @@ export namespace IncomeStatement { */ export interface HighestFee { type: 'HIGHEST_FEE' - created: HelsinkiDateTime + createdAt: HelsinkiDateTime endDate: LocalDate | null firstName: string - handled: boolean + handledAt: HelsinkiDateTime | null handlerNote: string id: UUID lastName: string + modifiedAt: HelsinkiDateTime personId: UUID + sentAt: HelsinkiDateTime | null startDate: LocalDate - updated: HelsinkiDateTime + status: IncomeStatementStatus } /** @@ -127,20 +131,22 @@ export namespace IncomeStatement { type: 'INCOME' alimonyPayer: boolean attachments: Attachment[] - created: HelsinkiDateTime + createdAt: HelsinkiDateTime endDate: LocalDate | null entrepreneur: Entrepreneur | null firstName: string gross: Gross | null - handled: boolean + handledAt: HelsinkiDateTime | null handlerNote: string id: UUID lastName: string + modifiedAt: HelsinkiDateTime otherInfo: string personId: UUID + sentAt: HelsinkiDateTime | null startDate: LocalDate + status: IncomeStatementStatus student: boolean - updated: HelsinkiDateTime } } @@ -154,7 +160,6 @@ export type IncomeStatement = IncomeStatement.ChildIncome | IncomeStatement.High * Generated from fi.espoo.evaka.incomestatement.IncomeStatementAwaitingHandler */ export interface IncomeStatementAwaitingHandler { - created: HelsinkiDateTime handlerNote: string id: UUID incomeEndDate: LocalDate | null @@ -163,6 +168,7 @@ export interface IncomeStatementAwaitingHandler { personLastName: string personName: string primaryCareArea: string | null + sentAt: HelsinkiDateTime startDate: LocalDate type: IncomeStatementType } @@ -215,13 +221,21 @@ export type IncomeStatementBody = IncomeStatementBody.ChildIncome | IncomeStatem * Generated from fi.espoo.evaka.incomestatement.IncomeStatementSortParam */ export type IncomeStatementSortParam = - | 'CREATED' + | 'SENT_AT' | 'START_DATE' | 'INCOME_END_DATE' | 'TYPE' | 'HANDLER_NOTE' | 'PERSON_NAME' +/** +* Generated from fi.espoo.evaka.incomestatement.IncomeStatementStatus +*/ +export type IncomeStatementStatus = + | 'DRAFT' + | 'SENT' + | 'HANDLED' + /** * Generated from fi.espoo.evaka.incomestatement.IncomeStatementType */ @@ -341,31 +355,37 @@ export function deserializeJsonEstimatedIncome(json: JsonOf): E export function deserializeJsonIncomeStatementChildIncome(json: JsonOf): IncomeStatement.ChildIncome { return { ...json, - created: HelsinkiDateTime.parseIso(json.created), + createdAt: HelsinkiDateTime.parseIso(json.createdAt), endDate: (json.endDate != null) ? LocalDate.parseIso(json.endDate) : null, - startDate: LocalDate.parseIso(json.startDate), - updated: HelsinkiDateTime.parseIso(json.updated) + handledAt: (json.handledAt != null) ? HelsinkiDateTime.parseIso(json.handledAt) : null, + modifiedAt: HelsinkiDateTime.parseIso(json.modifiedAt), + sentAt: (json.sentAt != null) ? HelsinkiDateTime.parseIso(json.sentAt) : null, + startDate: LocalDate.parseIso(json.startDate) } } export function deserializeJsonIncomeStatementHighestFee(json: JsonOf): IncomeStatement.HighestFee { return { ...json, - created: HelsinkiDateTime.parseIso(json.created), + createdAt: HelsinkiDateTime.parseIso(json.createdAt), endDate: (json.endDate != null) ? LocalDate.parseIso(json.endDate) : null, - startDate: LocalDate.parseIso(json.startDate), - updated: HelsinkiDateTime.parseIso(json.updated) + handledAt: (json.handledAt != null) ? HelsinkiDateTime.parseIso(json.handledAt) : null, + modifiedAt: HelsinkiDateTime.parseIso(json.modifiedAt), + sentAt: (json.sentAt != null) ? HelsinkiDateTime.parseIso(json.sentAt) : null, + startDate: LocalDate.parseIso(json.startDate) } } export function deserializeJsonIncomeStatementIncome(json: JsonOf): IncomeStatement.Income { return { ...json, - created: HelsinkiDateTime.parseIso(json.created), + createdAt: HelsinkiDateTime.parseIso(json.createdAt), endDate: (json.endDate != null) ? LocalDate.parseIso(json.endDate) : null, entrepreneur: (json.entrepreneur != null) ? deserializeJsonEntrepreneur(json.entrepreneur) : null, - startDate: LocalDate.parseIso(json.startDate), - updated: HelsinkiDateTime.parseIso(json.updated) + handledAt: (json.handledAt != null) ? HelsinkiDateTime.parseIso(json.handledAt) : null, + modifiedAt: HelsinkiDateTime.parseIso(json.modifiedAt), + sentAt: (json.sentAt != null) ? HelsinkiDateTime.parseIso(json.sentAt) : null, + startDate: LocalDate.parseIso(json.startDate) } } export function deserializeJsonIncomeStatement(json: JsonOf): IncomeStatement { @@ -381,8 +401,8 @@ export function deserializeJsonIncomeStatement(json: JsonOf): I export function deserializeJsonIncomeStatementAwaitingHandler(json: JsonOf): IncomeStatementAwaitingHandler { return { ...json, - created: HelsinkiDateTime.parseIso(json.created), incomeEndDate: (json.incomeEndDate != null) ? LocalDate.parseIso(json.incomeEndDate) : null, + sentAt: HelsinkiDateTime.parseIso(json.sentAt), startDate: LocalDate.parseIso(json.startDate) } } diff --git a/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx b/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx index 94afd525c7f..79dfd2611ce 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/en.tsx @@ -1913,6 +1913,9 @@ const en: Translations = {

), addNew: 'New income statement', + send: 'Send', + updateSent: 'Save changes', + saveAsDraft: 'Save and continue later', incomeInfo: 'Income information', incomeInstructions: 'Please submit an income statement after your child has got the place in early childhood education.', @@ -2153,6 +2156,8 @@ const en: Translations = { startDate: 'Valid as of', endDate: 'Valid until', createdAt: 'Created', + sentAt: 'Sent', + notSent: 'Not sent', handled: 'Processed', openIncomeStatement: 'Open form', deleteConfirm: 'Do you want to delete the income statement?', diff --git a/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx b/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx index 6d59f1d05a1..7c393cb1485 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/fi.tsx @@ -2158,6 +2158,9 @@ export default {

), addNew: 'Uusi tuloselvitys', + send: 'Lähetä', + updateSent: 'Tallenna muutokset', + saveAsDraft: 'Tallenna ja jatka myöhemmin', incomeInfo: 'Tulotiedot', incomeInstructions: 'Toimita tulotiedot vasta, kun lapsesi on saanut varhaiskasvatuspaikkapäätöksen.', @@ -2389,6 +2392,8 @@ export default { startDate: 'Voimassa alkaen', endDate: 'Voimassa asti', createdAt: 'Luotu', + sentAt: 'Lähetetty', + notSent: 'Ei lähetetty', handled: 'Käsitelty', openIncomeStatement: 'Avaa lomake', deleteConfirm: 'Haluatko poistaa tuloselvityksen?', diff --git a/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx b/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx index ce831ed4f0a..c06ac56c71d 100644 --- a/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx +++ b/frontend/src/lib-customizations/defaults/citizen/i18n/sv.tsx @@ -2156,6 +2156,9 @@ const sv: Translations = {

), addNew: 'Ny inkomstutredning', + send: 'Skicka', + updateSent: 'Spara ändringar', + saveAsDraft: 'Spara och fortsätt senare', incomeInfo: 'Inkomstuppgifter', incomeInstructions: 'Lämnä in en inkomstutredning eftersom din barn har fått platsen inom småbarnspedagogik.', @@ -2399,6 +2402,8 @@ const sv: Translations = { startDate: 'Gäller från och med', endDate: 'Gäller till och med', createdAt: 'Skapad', + sentAt: 'Skickat', + notSent: 'Inte skickat', handled: 'Handläggare', openIncomeStatement: 'Öppna blanketten', deleteConfirm: 'Vill du radera inkomstutredningen?', diff --git a/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx b/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx index 672fa6f5cba..3a7d66128a7 100755 --- a/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx +++ b/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx @@ -1944,7 +1944,7 @@ export const fi = { custodianTitle: 'Huollettavien tuloselvitykset', noIncomeStatements: 'Ei tuloselvityksiä', incomeStatementHeading: 'Asiakkaan tuloselvityslomake', - createdHeading: 'Saapumispäivä', + sentAtHeading: 'Saapumispäivä', handledHeading: 'Käsitelty', open: 'Avaa lomake', handled: 'Tuloselvitys käsitelty' @@ -2074,7 +2074,7 @@ export const fi = { title: 'Käsittelyä odottavat tuloselvitykset', customer: 'Asiakas', area: 'Alue', - created: 'Luotu', + sentAt: 'Lähetetty', startDate: 'Voimassa', incomeEndDate: 'Tulotieto päättyy', type: 'Tyyppi', diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/absence/AbsenceServiceIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/absence/AbsenceServiceIntegrationTest.kt index d854ca5c181..906d1450f4d 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/absence/AbsenceServiceIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/absence/AbsenceServiceIntegrationTest.kt @@ -43,6 +43,7 @@ import fi.espoo.evaka.shared.domain.DateRange import fi.espoo.evaka.shared.domain.FiniteDateRange import fi.espoo.evaka.shared.domain.HelsinkiDateTime import fi.espoo.evaka.shared.domain.TimeRange +import fi.espoo.evaka.shared.domain.isHoliday import fi.espoo.evaka.shared.domain.isWeekend import fi.espoo.evaka.snDaycareContractDays15 import fi.espoo.evaka.snDaycareFullDay35 @@ -71,7 +72,7 @@ class AbsenceServiceIntegrationTest : FullApplicationTest(resetDbBeforeEach = tr private val employeeId = EmployeeId(UUID.randomUUID()) private val placementStart: LocalDate = LocalDate.of(2019, 8, 1) private val placementEnd: LocalDate = LocalDate.of(2019, 12, 31) - private val now = HelsinkiDateTime.now() + private val now = HelsinkiDateTime.of(LocalDate.of(2024, 12, 28), LocalTime.of(15, 30)) private fun child(person: DevPerson) = GroupMonthCalendarChild( @@ -143,7 +144,7 @@ class AbsenceServiceIntegrationTest : FullApplicationTest(resetDbBeforeEach = tr .map { GroupMonthCalendarDay( date = it, - isOperationDay = !it.isWeekend(), + isOperationDay = !it.isWeekend() && !it.isHoliday(), isInHolidayPeriod = false, children = emptyList(), ) @@ -1970,7 +1971,7 @@ class AbsenceServiceIntegrationTest : FullApplicationTest(resetDbBeforeEach = tr optionId = serviceNeedOptionId, shiftCare = shiftCareType, confirmedBy = EvakaUserId(employeeId.raw), - confirmedAt = HelsinkiDateTime.now(), + confirmedAt = now, ) ) } diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/attachments/AttachmentsControllerIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/attachments/AttachmentsControllerIntegrationTest.kt index a144d81a58c..f5cc65a2a97 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/attachments/AttachmentsControllerIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/attachments/AttachmentsControllerIntegrationTest.kt @@ -10,13 +10,13 @@ import fi.espoo.evaka.FullApplicationTest import fi.espoo.evaka.application.ApplicationStatus import fi.espoo.evaka.attachment.AttachmentType import fi.espoo.evaka.incomestatement.IncomeStatementBody -import fi.espoo.evaka.incomestatement.createIncomeStatement import fi.espoo.evaka.insertApplication import fi.espoo.evaka.shared.ApplicationId import fi.espoo.evaka.shared.IncomeStatementId import fi.espoo.evaka.shared.auth.AuthenticatedUser import fi.espoo.evaka.shared.auth.CitizenAuthLevel import fi.espoo.evaka.shared.auth.asUser +import fi.espoo.evaka.shared.dev.DevIncomeStatement import fi.espoo.evaka.shared.dev.DevPersonType import fi.espoo.evaka.shared.dev.insert import fi.espoo.evaka.testAdult_5 @@ -61,17 +61,16 @@ class AttachmentsControllerIntegrationTest : FullApplicationTest(resetDbBeforeEa @Test fun `Citizen can upload income statement attachments up to a limit`() { val maxAttachments = evakaEnv.maxAttachmentsPerUser - val incomeStatementId = - db.transaction { - it.createIncomeStatement( - testAdult_5.id, - IncomeStatementBody.HighestFee(startDate = LocalDate.now(), endDate = null), - ) - } + val incomeStatement = + DevIncomeStatement( + personId = testAdult_5.id, + data = IncomeStatementBody.HighestFee(startDate = LocalDate.now(), endDate = null), + ) + db.transaction { it.insert(incomeStatement) } for (i in 1..maxAttachments) { - assertTrue(uploadIncomeStatementAttachment(incomeStatementId)) + assertTrue(uploadIncomeStatementAttachment(incomeStatement.id)) } - assertFalse(uploadIncomeStatementAttachment(incomeStatementId)) + assertFalse(uploadIncomeStatementAttachment(incomeStatement.id)) } @Test diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerCitizenIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerCitizenIntegrationTest.kt index 1b711230e14..565d34c3f6f 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerCitizenIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerCitizenIntegrationTest.kt @@ -21,13 +21,15 @@ import fi.espoo.evaka.shared.dev.DevPlacement import fi.espoo.evaka.shared.dev.insert import fi.espoo.evaka.shared.domain.BadRequest import fi.espoo.evaka.shared.domain.Forbidden -import fi.espoo.evaka.shared.domain.RealEvakaClock +import fi.espoo.evaka.shared.domain.HelsinkiDateTime +import fi.espoo.evaka.shared.domain.MockEvakaClock import fi.espoo.evaka.testAdult_1 import fi.espoo.evaka.testAdult_2 import fi.espoo.evaka.testArea import fi.espoo.evaka.testChild_1 import fi.espoo.evaka.testDaycare import java.time.LocalDate +import java.time.LocalTime import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertNotEquals @@ -44,6 +46,9 @@ class IncomeStatementControllerCitizenIntegrationTest : private lateinit var incomeStatementControllerCitizen: IncomeStatementControllerCitizen @Autowired private lateinit var attachmentsController: AttachmentsController + private val clock = + MockEvakaClock(HelsinkiDateTime.of(LocalDate.of(2024, 11, 18), LocalTime.of(15, 30))) + private val citizen = AuthenticatedUser.Citizen(testAdult_1.id, CitizenAuthLevel.STRONG) @BeforeEach @@ -72,9 +77,11 @@ class IncomeStatementControllerCitizenIntegrationTest : lastName = testAdult_1.lastName, startDate = LocalDate.of(2021, 4, 3), endDate = null, - created = incomeStatements[0].created, - updated = incomeStatements[0].updated, - handled = false, + createdAt = incomeStatements[0].createdAt, + modifiedAt = incomeStatements[0].modifiedAt, + sentAt = incomeStatements[0].sentAt, + status = IncomeStatementStatus.SENT, + handledAt = null, handlerNote = "", ) ), @@ -178,10 +185,12 @@ class IncomeStatementControllerCitizenIntegrationTest : student = false, alimonyPayer = true, otherInfo = "foo bar", - created = incomeStatements[0].created, - updated = incomeStatements[0].updated, - handled = false, + createdAt = incomeStatements[0].createdAt, + modifiedAt = incomeStatements[0].modifiedAt, + sentAt = incomeStatements[0].sentAt, + status = IncomeStatementStatus.SENT, handlerNote = "", + handledAt = null, attachments = listOf(), ) ), @@ -229,10 +238,12 @@ class IncomeStatementControllerCitizenIntegrationTest : startDate = LocalDate.of(2021, 4, 3), endDate = LocalDate.of(2021, 8, 9), otherInfo = "foo bar", - created = incomeStatements[0].created, - updated = incomeStatements[0].updated, - handled = false, + createdAt = incomeStatements[0].createdAt, + modifiedAt = incomeStatements[0].modifiedAt, + sentAt = incomeStatements[0].sentAt, + status = IncomeStatementStatus.SENT, handlerNote = "", + handledAt = null, attachments = listOf(), ) ), @@ -411,10 +422,12 @@ class IncomeStatementControllerCitizenIntegrationTest : student = false, alimonyPayer = true, otherInfo = "foo bar", - created = incomeStatements[0].created, - updated = incomeStatements[0].updated, - handled = false, + createdAt = incomeStatements[0].createdAt, + modifiedAt = incomeStatements[0].modifiedAt, + sentAt = incomeStatements[0].sentAt, + status = IncomeStatementStatus.SENT, handlerNote = "", + handledAt = null, attachments = listOf(idToAttachment(attachmentId)), ) ), @@ -448,10 +461,12 @@ class IncomeStatementControllerCitizenIntegrationTest : startDate = LocalDate.of(2021, 4, 3), endDate = null, otherInfo = "foo bar", - created = incomeStatements[0].created, - updated = incomeStatements[0].updated, - handled = false, + createdAt = incomeStatements[0].createdAt, + modifiedAt = incomeStatements[0].modifiedAt, + sentAt = incomeStatements[0].sentAt, + status = IncomeStatementStatus.SENT, handlerNote = "", + handledAt = null, attachments = listOf(idToAttachment(attachmentId)), ) ), @@ -564,10 +579,12 @@ class IncomeStatementControllerCitizenIntegrationTest : ) ) - val incomeStatement = getIncomeStatements().data[0] + val original = getIncomeStatements().data[0] + + clock.tick() updateIncomeStatement( - incomeStatement.id, + original.id, IncomeStatementBody.Income( startDate = LocalDate.of(2021, 6, 11), endDate = LocalDate.of(2022, 6, 1), @@ -597,12 +614,12 @@ class IncomeStatementControllerCitizenIntegrationTest : ), ) - val updated = getIncomeStatement(incomeStatement.id).updated - assertNotEquals(incomeStatement.updated, updated) + val modifiedAt = getIncomeStatement(original.id).modifiedAt + assertNotEquals(original.modifiedAt, modifiedAt) assertEquals( IncomeStatement.Income( - id = incomeStatement.id, + id = original.id, personId = testAdult_1.id, firstName = testAdult_1.firstName, lastName = testAdult_1.lastName, @@ -630,13 +647,15 @@ class IncomeStatementControllerCitizenIntegrationTest : student = true, alimonyPayer = false, otherInfo = "", - created = incomeStatement.created, - updated = updated, - handled = false, + createdAt = original.createdAt, + modifiedAt = modifiedAt, + sentAt = original.sentAt, + status = IncomeStatementStatus.SENT, handlerNote = "", + handledAt = null, attachments = listOf(idToAttachment(attachment2), idToAttachment(attachment3)), ), - getIncomeStatement(incomeStatement.id), + getIncomeStatement(original.id), ) } @@ -674,7 +693,7 @@ class IncomeStatementControllerCitizenIntegrationTest : markIncomeStatementHandled(incomeStatement.id, employee.id, "foo bar") val handled = getIncomeStatements().data.first() - assertEquals(true, handled.handled) + assertEquals(IncomeStatementStatus.HANDLED, handled.status) assertEquals("", handled.handlerNote) assertThrows { deleteIncomeStatement(incomeStatement.id) } @@ -721,7 +740,10 @@ class IncomeStatementControllerCitizenIntegrationTest : sql( """ UPDATE income_statement -SET handler_id = ${bind(handlerId)}, handler_note = ${bind(note)} +SET handler_id = ${bind(handlerId)}, + handler_note = ${bind(note)}, + status = 'HANDLED'::income_statement_status, + handled_at = ${bind(clock.now())} WHERE id = ${bind(id)} """ ) @@ -812,8 +834,8 @@ WHERE id = ${bind(id)} it.insert( DevPlacement( childId = testChild_1.id, - startDate = LocalDate.now(), - endDate = LocalDate.now(), + startDate = clock.today(), + endDate = clock.today(), type = PlacementType.DAYCARE, unitId = testDaycare.id, ) @@ -831,8 +853,8 @@ WHERE id = ${bind(id)} it.insert( DevPlacement( childId = testChild_1.id, - startDate = LocalDate.now().minusWeeks(1), - endDate = LocalDate.now().minusWeeks(1), + startDate = clock.today().minusWeeks(1), + endDate = clock.today().minusWeeks(1), type = PlacementType.DAYCARE, unitId = testDaycare.id, ) @@ -846,7 +868,7 @@ WHERE id = ${bind(id)} return incomeStatementControllerCitizen.getIncomeStatements( dbInstance(), citizen, - RealEvakaClock(), + clock, page = page, ) } @@ -858,75 +880,73 @@ WHERE id = ${bind(id)} return incomeStatementControllerCitizen.getChildIncomeStatements( dbInstance(), citizen, - RealEvakaClock(), + clock, childId, page = page, ) } private fun getIncomeStatement(id: IncomeStatementId): IncomeStatement { - return incomeStatementControllerCitizen.getIncomeStatement( - dbInstance(), - citizen, - RealEvakaClock(), - id, - ) + return incomeStatementControllerCitizen.getIncomeStatement(dbInstance(), citizen, clock, id) } private fun getIncomeStatementChildren(): List { return incomeStatementControllerCitizen.getIncomeStatementChildren( dbInstance(), citizen, - RealEvakaClock(), + clock, ) } - private fun createIncomeStatement(body: IncomeStatementBody) { + private fun createIncomeStatement(body: IncomeStatementBody, draft: Boolean = false) { incomeStatementControllerCitizen.createIncomeStatement( dbInstance(), citizen, - RealEvakaClock(), + clock, body, + draft, ) } private fun createIncomeStatementForChild( body: IncomeStatementBody.ChildIncome, childId: ChildId, + draft: Boolean = false, ) { incomeStatementControllerCitizen.createChildIncomeStatement( dbInstance(), citizen, - RealEvakaClock(), + clock, childId, body, + draft, ) } - private fun updateIncomeStatement(id: IncomeStatementId, body: IncomeStatementBody) { + private fun updateIncomeStatement( + id: IncomeStatementId, + body: IncomeStatementBody, + draft: Boolean = false, + ) { incomeStatementControllerCitizen.updateIncomeStatement( dbInstance(), citizen, - RealEvakaClock(), + clock, id, body, + draft, ) } private fun deleteIncomeStatement(id: IncomeStatementId) { - incomeStatementControllerCitizen.deleteIncomeStatement( - dbInstance(), - citizen, - RealEvakaClock(), - id, - ) + incomeStatementControllerCitizen.deleteIncomeStatement(dbInstance(), citizen, clock, id) } private fun uploadAttachment(user: AuthenticatedUser.Citizen = citizen): AttachmentId { return attachmentsController.uploadIncomeStatementAttachmentCitizen( dbInstance(), user, - RealEvakaClock(), + clock, incomeStatementId = null, file = MockMultipartFile("file", "evaka-logo.png", "image/png", pngFile.readBytes()), ) @@ -942,7 +962,7 @@ WHERE id = ${bind(id)} return attachmentsController.uploadIncomeStatementAttachmentEmployee( dbInstance(), user, - RealEvakaClock(), + clock, incomeStatementId, MockMultipartFile("file", "evaka-logo.png", "image/png", pngFile.readBytes()), ) diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerIntegrationTest.kt index 23ce7ce6219..f08ae0d7ca7 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerIntegrationTest.kt @@ -24,6 +24,7 @@ import fi.espoo.evaka.shared.dev.DevDaycare import fi.espoo.evaka.shared.dev.DevEmployee import fi.espoo.evaka.shared.dev.DevGuardian import fi.espoo.evaka.shared.dev.DevIncome +import fi.espoo.evaka.shared.dev.DevIncomeStatement import fi.espoo.evaka.shared.dev.DevParentship import fi.espoo.evaka.shared.dev.DevPersonType import fi.espoo.evaka.shared.dev.DevPlacement @@ -93,13 +94,17 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val startDate = today val endDate = today.plusDays(30) - val id = - db.transaction { tx -> - tx.createIncomeStatement( - citizenId, - IncomeStatementBody.HighestFee(startDate, endDate), - ) - } + val incomeStatement = + DevIncomeStatement( + personId = testAdult_1.id, + data = IncomeStatementBody.HighestFee(startDate, endDate), + status = IncomeStatementStatus.SENT, + createdAt = now.minusHours(10), + modifiedAt = now.minusHours(7), + sentAt = now.minusHours(5), + ) + db.transaction { it.insert(incomeStatement) } + val id = incomeStatement.id val incomeStatement1 = getIncomeStatement(id) assertEquals( @@ -110,9 +115,11 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo lastName = testAdult_1.lastName, startDate = startDate, endDate = endDate, - created = incomeStatement1.created, - updated = incomeStatement1.updated, - handled = false, + createdAt = incomeStatement.createdAt, + modifiedAt = incomeStatement.modifiedAt, + sentAt = incomeStatement.sentAt, + status = IncomeStatementStatus.SENT, + handledAt = null, handlerNote = "", ), incomeStatement1, @@ -132,9 +139,11 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo lastName = testAdult_1.lastName, startDate = startDate, endDate = endDate, - created = incomeStatement1.created, - updated = incomeStatement2.updated, - handled = true, + createdAt = incomeStatement.createdAt, + modifiedAt = incomeStatement2.modifiedAt, + sentAt = incomeStatement.sentAt, + status = IncomeStatementStatus.HANDLED, + handledAt = now, handlerNote = "is cool", ), incomeStatement2, @@ -154,23 +163,30 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo lastName = testAdult_1.lastName, startDate = startDate, endDate = endDate, - created = incomeStatement1.created, - updated = incomeStatement3.updated, - handled = false, + createdAt = incomeStatement3.createdAt, + modifiedAt = incomeStatement3.modifiedAt, + sentAt = incomeStatement3.sentAt, + status = IncomeStatementStatus.SENT, + handledAt = null, handlerNote = "is not cool", ), incomeStatement3, ) - assertEquals(listOf(false), getIncomeStatements(citizenId).data.map { it.handled }) + assertEquals( + listOf(false), + getIncomeStatements(citizenId).data.map { it.status == IncomeStatementStatus.HANDLED }, + ) } @Test fun `add an attachment`() { - val id = - db.transaction { tx -> - tx.createIncomeStatement( - citizenId, + val devIncomeStatement = + DevIncomeStatement( + personId = testAdult_1.id, + status = IncomeStatementStatus.DRAFT, + sentAt = null, + data = IncomeStatementBody.Income( startDate = today, endDate = null, @@ -187,8 +203,9 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo otherInfo = "", attachmentIds = listOf(), ), - ) - } + ) + db.transaction { it.insert(devIncomeStatement) } + val id = devIncomeStatement.id val attachmentId = uploadAttachment(id) @@ -221,9 +238,11 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo uploadedByEmployee = true, ) ), - created = incomeStatement.created, - updated = incomeStatement.updated, - handled = false, + createdAt = incomeStatement.createdAt, + modifiedAt = incomeStatement.modifiedAt, + sentAt = null, + status = IncomeStatementStatus.DRAFT, + handledAt = null, handlerNote = "", ), getIncomeStatement(id), @@ -233,34 +252,46 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo private fun createTestIncomeStatement( personId: PersonId, startDate: LocalDate? = null, + sentAt: HelsinkiDateTime = now, ): IncomeStatement { - val id = - db.transaction { tx -> - tx.createIncomeStatement( - personId, + val incomeStatement = + DevIncomeStatement( + personId = personId, + data = IncomeStatementBody.HighestFee(startDate = startDate ?: today, endDate = null), - ) - } - return db.read { it.readIncomeStatementForPerson(personId, id, true)!! } + status = IncomeStatementStatus.SENT, + sentAt = sentAt, + ) + + return db.transaction { tx -> + tx.insert(incomeStatement) + tx.readIncomeStatementForPerson(personId, incomeStatement.id, true)!! + } } private fun createChildTestIncomeStatement( personId: PersonId, startDate: LocalDate? = null, + sentAt: HelsinkiDateTime = now, ): IncomeStatement { - val id = - db.transaction { tx -> - tx.createIncomeStatement( - personId, + val incomeStatement = + DevIncomeStatement( + personId = personId, + data = IncomeStatementBody.ChildIncome( startDate = startDate ?: today, endDate = null, attachmentIds = listOf(), otherInfo = "", ), - ) - } - return db.read { it.readIncomeStatementForPerson(personId, id, true)!! } + status = IncomeStatementStatus.SENT, + sentAt = sentAt, + ) + + return db.transaction { tx -> + tx.insert(incomeStatement) + tx.readIncomeStatementForPerson(personId, incomeStatement.id, true)!! + } } @Test @@ -407,20 +438,21 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ) } - val incomeStatement1 = createTestIncomeStatement(citizenId) - val incomeStatement2 = createTestIncomeStatement(testAdult_2.id) - val incomeStatement3 = createTestIncomeStatement(testAdult_3.id) - val incomeStatement4 = createTestIncomeStatement(testAdult_4.id) - val incomeStatement5 = createTestIncomeStatement(testAdult_5.id) - val incomeStatement6 = createTestIncomeStatement(testAdult_6.id) - val incomeStatement7 = createChildTestIncomeStatement(testChild_1.id) + val incomeStatement1 = createTestIncomeStatement(citizenId, sentAt = now.minusHours(7)) + val incomeStatement2 = createTestIncomeStatement(testAdult_2.id, sentAt = now.minusHours(6)) + val incomeStatement3 = createTestIncomeStatement(testAdult_3.id, sentAt = now.minusHours(5)) + val incomeStatement4 = createTestIncomeStatement(testAdult_4.id, sentAt = now.minusHours(4)) + val incomeStatement5 = createTestIncomeStatement(testAdult_5.id, sentAt = now.minusHours(3)) + val incomeStatement6 = createTestIncomeStatement(testAdult_6.id, sentAt = now.minusHours(2)) + val incomeStatement7 = + createChildTestIncomeStatement(testChild_1.id, sentAt = now.minusHours(1)) assertEquals( PagedIncomeStatementsAwaitingHandler( listOf( IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = incomeStatement1.created, + sentAt = incomeStatement1.sentAt!!, startDate = incomeStatement1.startDate, incomeEndDate = incomeDate1, handlerNote = "", @@ -432,7 +464,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -444,7 +476,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement3.id, - created = incomeStatement3.created, + sentAt = incomeStatement3.sentAt!!, startDate = incomeStatement3.startDate, incomeEndDate = null, handlerNote = "", @@ -456,7 +488,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement4.id, - created = incomeStatement4.created, + sentAt = incomeStatement4.sentAt!!, startDate = incomeStatement4.startDate, incomeEndDate = null, handlerNote = "", @@ -468,7 +500,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement5.id, - created = incomeStatement5.created, + sentAt = incomeStatement5.sentAt!!, startDate = incomeStatement5.startDate, incomeEndDate = null, handlerNote = "", @@ -480,7 +512,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement6.id, - created = incomeStatement6.created, + sentAt = incomeStatement6.sentAt!!, startDate = incomeStatement6.startDate, incomeEndDate = null, handlerNote = "", @@ -492,7 +524,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement7.id, - created = incomeStatement7.created, + sentAt = incomeStatement7.sentAt!!, startDate = incomeStatement7.startDate, incomeEndDate = null, handlerNote = "", @@ -564,7 +596,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo listOf( IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -641,7 +673,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo listOf( IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -713,7 +745,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo listOf( IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -782,12 +814,12 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val incomeStatement1 = createTestIncomeStatement(citizenId) val incomeStatement2 = createTestIncomeStatement(testAdult_2.id) - val newCreated = HelsinkiDateTime.of(today.minusDays(2), LocalTime.of(12, 0)) + val newSentAt = HelsinkiDateTime.of(today.minusDays(2), LocalTime.of(12, 0)) db.transaction { it.execute { sql( - "UPDATE income_statement SET created = ${bind(newCreated)} WHERE id = ${bind(incomeStatement1.id)}" + "UPDATE income_statement SET sent_at = ${bind(newSentAt)} WHERE id = ${bind(incomeStatement1.id)}" ) } } @@ -797,7 +829,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo listOf( IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = newCreated, + sentAt = newSentAt, startDate = incomeStatement1.startDate, incomeEndDate = null, handlerNote = "", @@ -824,7 +856,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo listOf( IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -907,12 +939,12 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val incomeStatement2 = createTestIncomeStatement(testAdult_2.id) val incomeStatement3 = createTestIncomeStatement(testAdult_3.id) - val newCreated = HelsinkiDateTime.of(LocalDate.of(2022, 10, 17), LocalTime.of(11, 4)) + val newSentAt = HelsinkiDateTime.of(LocalDate.of(2022, 10, 17), LocalTime.of(11, 4)) db.transaction { @Suppress("DEPRECATION") - it.createUpdate("UPDATE income_statement SET created = :newCreated WHERE id = :id") - .bind("newCreated", newCreated) + it.createUpdate("UPDATE income_statement SET sent_at = :newSentAt WHERE id = :id") + .bind("newSentAt", newSentAt) .bind("id", incomeStatement1.id) .execute() } @@ -922,7 +954,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo listOf( IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = newCreated, + sentAt = newSentAt, startDate = incomeStatement1.startDate, incomeEndDate = null, handlerNote = "", @@ -934,7 +966,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -946,7 +978,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement3.id, - created = incomeStatement3.created, + sentAt = incomeStatement3.sentAt!!, startDate = incomeStatement3.startDate, incomeEndDate = null, handlerNote = "", @@ -970,7 +1002,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo listOf( IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -995,7 +1027,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo listOf( IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = newCreated, + sentAt = newSentAt, startDate = incomeStatement1.startDate, incomeEndDate = null, handlerNote = "", @@ -1007,7 +1039,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo ), IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -1079,19 +1111,19 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val incomeStatement2 = createTestIncomeStatement(testAdult_2.id, startDate = LocalDate.of(2022, 10, 13)) - val newCreated = HelsinkiDateTime.of(today.minusDays(2), LocalTime.of(12, 0)) + val newSentAt = HelsinkiDateTime.of(today.minusDays(2), LocalTime.of(12, 0)) db.transaction { @Suppress("DEPRECATION") - it.createUpdate("UPDATE income_statement SET created = :newCreated") - .bind("newCreated", newCreated) + it.createUpdate("UPDATE income_statement SET sent_at = :newSentAt") + .bind("newSentAt", newSentAt) .execute() } val expected1 = IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = newCreated, + sentAt = newSentAt, startDate = incomeStatement1.startDate, incomeEndDate = null, handlerNote = "", @@ -1104,7 +1136,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected2 = IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = newCreated, + sentAt = newSentAt, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -1202,7 +1234,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected1 = IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = incomeStatement1.created, + sentAt = incomeStatement1.sentAt!!, startDate = incomeStatement1.startDate, incomeEndDate = incomeRange1.end, handlerNote = "", @@ -1215,7 +1247,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected2 = IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = incomeRange2.end, handlerNote = "", @@ -1295,7 +1327,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected1 = IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = incomeStatement1.created, + sentAt = incomeStatement1.sentAt!!, startDate = incomeStatement1.startDate, incomeEndDate = null, handlerNote = "", @@ -1308,7 +1340,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected2 = IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", @@ -1402,7 +1434,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected1 = IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = incomeStatement1.created, + sentAt = incomeStatement1.sentAt!!, startDate = incomeStatement1.startDate, incomeEndDate = null, handlerNote = "a", @@ -1415,7 +1447,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected2 = IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "b", @@ -1495,7 +1527,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected1 = IncomeStatementAwaitingHandler( id = incomeStatement1.id, - created = incomeStatement1.created, + sentAt = incomeStatement1.sentAt!!, startDate = incomeStatement1.startDate, incomeEndDate = null, handlerNote = "", @@ -1508,7 +1540,7 @@ class IncomeStatementControllerIntegrationTest : FullApplicationTest(resetDbBefo val expected2 = IncomeStatementAwaitingHandler( id = incomeStatement2.id, - created = incomeStatement2.created, + sentAt = incomeStatement2.sentAt!!, startDate = incomeStatement2.startDate, incomeEndDate = null, handlerNote = "", diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/FeeDecisionIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/FeeDecisionIntegrationTest.kt index 6f15ec40a4f..17ba44a33ae 100755 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/FeeDecisionIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/FeeDecisionIntegrationTest.kt @@ -10,8 +10,7 @@ import fi.espoo.evaka.emailclient.Email import fi.espoo.evaka.emailclient.IEmailMessageProvider import fi.espoo.evaka.emailclient.MockEmailClient import fi.espoo.evaka.incomestatement.IncomeStatementBody -import fi.espoo.evaka.incomestatement.createIncomeStatement -import fi.espoo.evaka.incomestatement.updateIncomeStatementHandled +import fi.espoo.evaka.incomestatement.IncomeStatementStatus import fi.espoo.evaka.invoicing.controller.DistinctiveParams import fi.espoo.evaka.invoicing.controller.FeeDecisionController import fi.espoo.evaka.invoicing.controller.FeeDecisionSortParam @@ -43,6 +42,7 @@ import fi.espoo.evaka.shared.auth.AuthenticatedUser import fi.espoo.evaka.shared.auth.UserRole import fi.espoo.evaka.shared.dev.DevCareArea import fi.espoo.evaka.shared.dev.DevDaycare +import fi.espoo.evaka.shared.dev.DevIncomeStatement import fi.espoo.evaka.shared.dev.DevParentship import fi.espoo.evaka.shared.dev.DevPerson import fi.espoo.evaka.shared.dev.DevPersonType @@ -563,73 +563,122 @@ class FeeDecisionIntegrationTest : FullApplicationTest(resetDbBeforeEach = true) decisionWithOpenChildStatement, ) ) - val adult1StatementId = - tx.createIncomeStatement( - body = + + tx.insert( + DevIncomeStatement( + personId = testAdult_1.id, + data = IncomeStatementBody.HighestFee( clock.today().minusMonths(2), clock.today().minusMonths(1), ), - personId = testAdult_1.id, + status = IncomeStatementStatus.HANDLED, + handledAt = clock.now(), + handlerId = testDecisionMaker_1.id, ) + ) + // testAdult_2 statement not submitted - tx.updateIncomeStatementHandled(adult1StatementId, "handled", testDecisionMaker_1.id) - val adult3StatementId = - tx.createIncomeStatement( - body = + tx.insert( + DevIncomeStatement( + personId = testAdult_2.id, + data = IncomeStatementBody.HighestFee( clock.today().minusMonths(2), clock.today().minusMonths(1), ), + status = IncomeStatementStatus.DRAFT, + sentAt = null, + ) + ) + + tx.insert( + DevIncomeStatement( personId = testAdult_3.id, + data = + IncomeStatementBody.HighestFee( + clock.today().minusMonths(2), + clock.today().minusMonths(1), + ), + status = IncomeStatementStatus.HANDLED, + handledAt = clock.now(), + handlerId = testDecisionMaker_1.id, ) - tx.updateIncomeStatementHandled(adult3StatementId, "handled", testDecisionMaker_1.id) - tx.createIncomeStatement( - body = - IncomeStatementBody.HighestFee( - clock.today().minusMonths(2), - clock.today().minusMonths(1), - ), - personId = testAdult_4.id, ) + // testAdult_4 statement not handled - tx.createIncomeStatement( - body = - IncomeStatementBody.HighestFee( - clock.today().minusMonths(20), - clock.today().minusMonths(14).minusDays(1), - ), - personId = testAdult_5.id, + tx.insert( + DevIncomeStatement( + personId = testAdult_4.id, + data = + IncomeStatementBody.HighestFee( + clock.today().minusMonths(2), + clock.today().minusMonths(1), + ), + status = IncomeStatementStatus.SENT, + handledAt = null, + handlerId = null, + ) ) + // testAdult_5 statement not handled - tx.createIncomeStatement( - body = - IncomeStatementBody.HighestFee( - clock.today().plusDays(1), - clock.today().plusMonths(12), - ), - personId = testAdult_6.id, + tx.insert( + DevIncomeStatement( + personId = testAdult_5.id, + data = + IncomeStatementBody.HighestFee( + clock.today().minusMonths(20), + clock.today().minusMonths(14).minusDays(1), + ), + status = IncomeStatementStatus.SENT, + handledAt = null, + handlerId = null, + ) ) + // testAdult_6 statement not handled - val adult7StatementId = - tx.createIncomeStatement( - body = + tx.insert( + DevIncomeStatement( + personId = testAdult_6.id, + data = + IncomeStatementBody.HighestFee( + clock.today().plusDays(1), + clock.today().plusMonths(12), + ), + status = IncomeStatementStatus.SENT, + handledAt = null, + handlerId = null, + ) + ) + + tx.insert( + DevIncomeStatement( + personId = testAdult_7.id, + data = IncomeStatementBody.HighestFee( clock.today().minusMonths(2), clock.today().minusMonths(1), ), - personId = testAdult_7.id, + status = IncomeStatementStatus.HANDLED, + handledAt = clock.now(), + handlerId = testDecisionMaker_1.id, ) - tx.updateIncomeStatementHandled(adult7StatementId, "handled", testDecisionMaker_1.id) - tx.createIncomeStatement( - body = - IncomeStatementBody.HighestFee( - clock.today().minusMonths(2), - clock.today().minusMonths(1), - ), - personId = testChild_4.id, ) + // testChild_4 statement not handled + tx.insert( + DevIncomeStatement( + personId = testChild_4.id, + data = + IncomeStatementBody.HighestFee( + clock.today().minusMonths(2), + clock.today().minusMonths(1), + ), + status = IncomeStatementStatus.SENT, + handledAt = null, + handlerId = null, + ) + ) } val result = @@ -659,36 +708,59 @@ class FeeDecisionIntegrationTest : FullApplicationTest(resetDbBeforeEach = true) db.transaction { tx -> tx.upsertFeeDecisions(listOf(decisionWithHandledStatements, decisionWithOpenStatements)) - val adult1StatementId = - tx.createIncomeStatement( - body = + tx.insert( + DevIncomeStatement( + personId = testAdult_1.id, + data = IncomeStatementBody.HighestFee( clock.today().minusMonths(2), clock.today().minusMonths(1), ), - personId = testAdult_1.id, + status = IncomeStatementStatus.HANDLED, + handledAt = clock.now(), + handlerId = testDecisionMaker_1.id, ) + ) // testAdult_2 statement not submitted - tx.updateIncomeStatementHandled(adult1StatementId, "handled", testDecisionMaker_1.id) - val adult3StatementId = - tx.createIncomeStatement( - body = + tx.insert( + DevIncomeStatement( + personId = testAdult_2.id, + data = IncomeStatementBody.HighestFee( clock.today().minusMonths(2), clock.today().minusMonths(1), ), + status = IncomeStatementStatus.DRAFT, + sentAt = null, + ) + ) + tx.insert( + DevIncomeStatement( personId = testAdult_3.id, + data = + IncomeStatementBody.HighestFee( + clock.today().minusMonths(2), + clock.today().minusMonths(1), + ), + status = IncomeStatementStatus.HANDLED, + handledAt = clock.now(), + handlerId = testDecisionMaker_1.id, ) - tx.updateIncomeStatementHandled(adult3StatementId, "handled", testDecisionMaker_1.id) - tx.createIncomeStatement( - body = - IncomeStatementBody.HighestFee( - clock.today().minusMonths(2), - clock.today().minusMonths(1), - ), - personId = testAdult_4.id, ) // testAdult_4 statement not handled + tx.insert( + DevIncomeStatement( + personId = testAdult_4.id, + data = + IncomeStatementBody.HighestFee( + clock.today().minusMonths(2), + clock.today().minusMonths(1), + ), + status = IncomeStatementStatus.SENT, + handledAt = null, + handlerId = null, + ) + ) } val result = diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/service/OutdatedIncomeNotificationsIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/service/OutdatedIncomeNotificationsIntegrationTest.kt index f99a1c6a5f7..de438a98813 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/service/OutdatedIncomeNotificationsIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/invoicing/service/OutdatedIncomeNotificationsIntegrationTest.kt @@ -8,7 +8,10 @@ import com.fasterxml.jackson.databind.json.JsonMapper import fi.espoo.evaka.FullApplicationTest import fi.espoo.evaka.emailclient.Email import fi.espoo.evaka.emailclient.MockEmailClient -import fi.espoo.evaka.incomestatement.IncomeStatementType +import fi.espoo.evaka.incomestatement.Gross +import fi.espoo.evaka.incomestatement.IncomeSource +import fi.espoo.evaka.incomestatement.IncomeStatementBody +import fi.espoo.evaka.incomestatement.IncomeStatementStatus import fi.espoo.evaka.insertServiceNeedOptions import fi.espoo.evaka.invoicing.data.findFeeDecisionsForHeadOfFamily import fi.espoo.evaka.invoicing.data.getIncomesForPerson @@ -311,11 +314,9 @@ class OutdatedIncomeNotificationsIntegrationTest : FullApplicationTest(resetDbBe it.insert( DevIncomeStatement( - id = IncomeStatementId(UUID.randomUUID()), personId = fridgeHeadOfChildId, - startDate = incomeExpirationDate.plusDays(1), - type = IncomeStatementType.INCOME, - grossEstimatedMonthlyIncome = 42, + data = createGrossIncome(incomeExpirationDate.plusDays(1)), + status = IncomeStatementStatus.SENT, handlerId = null, ) ) @@ -342,10 +343,10 @@ class OutdatedIncomeNotificationsIntegrationTest : FullApplicationTest(resetDbBe DevIncomeStatement( id = IncomeStatementId(UUID.randomUUID()), personId = fridgeHeadOfChildId, - startDate = incomeExpirationDate.plusDays(1), - type = IncomeStatementType.INCOME, - grossEstimatedMonthlyIncome = 42, + data = createGrossIncome(incomeExpirationDate.plusDays(1)), + status = IncomeStatementStatus.HANDLED, handlerId = employeeId, + handledAt = clock.now(), ) ) } @@ -371,9 +372,8 @@ class OutdatedIncomeNotificationsIntegrationTest : FullApplicationTest(resetDbBe DevIncomeStatement( id = IncomeStatementId(UUID.randomUUID()), personId = fridgeHeadOfChildId, - startDate = incomeExpirationDate.plusDays(1), - type = IncomeStatementType.INCOME, - grossEstimatedMonthlyIncome = 42, + data = createGrossIncome(incomeExpirationDate.plusDays(1)), + status = IncomeStatementStatus.SENT, ) ) } @@ -686,6 +686,24 @@ class OutdatedIncomeNotificationsIntegrationTest : FullApplicationTest(resetDbBe ) } + private fun createGrossIncome(startDate: LocalDate) = + IncomeStatementBody.Income( + startDate = startDate, + endDate = null, + gross = + Gross( + incomeSource = IncomeSource.INCOMES_REGISTER, + estimatedMonthlyIncome = 42, + otherIncome = emptySet(), + otherIncomeInfo = "", + ), + entrepreneur = null, + student = false, + alimonyPayer = false, + otherInfo = "", + attachmentIds = emptyList(), + ) + private fun getEmails(): List { scheduledJobs.sendOutdatedIncomeNotifications(db, clock) asyncJobRunner.runPendingJobsSync(clock) diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/InactivePeopleCleanupIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/InactivePeopleCleanupIntegrationTest.kt index f0f23d180d5..48586f8f64e 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/InactivePeopleCleanupIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/pis/InactivePeopleCleanupIntegrationTest.kt @@ -13,7 +13,7 @@ import fi.espoo.evaka.assistanceneed.decision.ServiceOptions import fi.espoo.evaka.assistanceneed.decision.StructuralMotivationOptions import fi.espoo.evaka.assistanceneed.preschooldecision.AssistanceNeedPreschoolDecisionForm import fi.espoo.evaka.assistanceneed.preschooldecision.AssistanceNeedPreschoolDecisionType -import fi.espoo.evaka.incomestatement.IncomeStatementType +import fi.espoo.evaka.incomestatement.IncomeStatementBody import fi.espoo.evaka.messaging.MessageType import fi.espoo.evaka.messaging.getCitizenMessageAccount import fi.espoo.evaka.messaging.insertMessage @@ -26,7 +26,6 @@ import fi.espoo.evaka.pis.service.deleteGuardianRelationship import fi.espoo.evaka.pis.service.insertGuardian import fi.espoo.evaka.shared.AssistanceNeedPreschoolDecisionId import fi.espoo.evaka.shared.EmployeeId -import fi.espoo.evaka.shared.IncomeStatementId import fi.espoo.evaka.shared.PedagogicalDocumentId import fi.espoo.evaka.shared.PersonId import fi.espoo.evaka.shared.dev.DevAssistanceNeedDecision @@ -307,11 +306,8 @@ class InactivePeopleCleanupIntegrationTest : PureJdbiTest(resetDbBeforeEach = tr tx.insert(testAdult_1, DevPersonType.RAW_ROW) tx.insert( DevIncomeStatement( - IncomeStatementId(UUID.randomUUID()), - testAdult_1.id, - LocalDate.now(), - IncomeStatementType.INCOME, - 42, + personId = testAdult_1.id, + data = IncomeStatementBody.HighestFee(LocalDate.now(), null), ) ) } diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/db/SchemaConventionsTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/db/SchemaConventionsTest.kt index 6bff4cc8e50..af50d1a3416 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/db/SchemaConventionsTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/db/SchemaConventionsTest.kt @@ -79,7 +79,6 @@ class SchemaConventionsTest : PureJdbiTest(resetDbBeforeEach = false) { "holiday_period_questionnaire", "holiday_questionnaire_answer", "income_notification", - "income_statement", "invoice_correction", "koski_study_right", "language_emphasis", @@ -173,7 +172,6 @@ class SchemaConventionsTest : PureJdbiTest(resetDbBeforeEach = false) { "holiday_period_questionnaire", "holiday_questionnaire_answer", "income_notification", - "income_statement", "invoice_correction", "koski_study_right", "language_emphasis", diff --git a/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatement.kt b/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatement.kt index ee7785f66c3..4c32ac3928e 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatement.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatement.kt @@ -149,42 +149,40 @@ fun validateIncomeStatementBody(body: IncomeStatementBody): Boolean { } } -fun createIncomeStatement( - dbc: Database.Connection, - incomeStatementPersonId: PersonId, - uploadedBy: AuthenticatedUser.Citizen, +fun createValidatedIncomeStatement( + tx: Database.Transaction, + user: AuthenticatedUser.Citizen, + now: HelsinkiDateTime, + personId: PersonId, // may be either the user or their child body: IncomeStatementBody, + draft: Boolean, ): IncomeStatementId { - if (!validateIncomeStatementBody(body)) throw BadRequest("Invalid income statement") + if (!draft && !validateIncomeStatementBody(body)) throw BadRequest("Invalid income statement") - if ( - dbc.read { tx -> - tx.incomeStatementExistsForStartDate(incomeStatementPersonId, body.startDate) - } - ) { + if (tx.incomeStatementExistsForStartDate(user.id, body.startDate)) { throw BadRequest("An income statement for this start date already exists") } - return dbc.transaction { tx -> - val incomeStatementId = tx.createIncomeStatement(incomeStatementPersonId, body) - when (body) { - is IncomeStatementBody.Income -> - tx.associateOrphanAttachments( - uploadedBy.evakaUserId, - AttachmentParent.IncomeStatement(incomeStatementId), - body.attachmentIds, - ) - is IncomeStatementBody.ChildIncome -> { - tx.associateOrphanAttachments( - uploadedBy.evakaUserId, - AttachmentParent.IncomeStatement(incomeStatementId), - body.attachmentIds, - ) - } - else -> {} + val incomeStatementId = tx.insertIncomeStatement(user.evakaUserId, now, personId, body, draft) + + when (body) { + is IncomeStatementBody.Income -> + tx.associateOrphanAttachments( + user.evakaUserId, + AttachmentParent.IncomeStatement(incomeStatementId), + body.attachmentIds, + ) + is IncomeStatementBody.ChildIncome -> { + tx.associateOrphanAttachments( + user.evakaUserId, + AttachmentParent.IncomeStatement(incomeStatementId), + body.attachmentIds, + ) } - incomeStatementId + else -> {} } + + return incomeStatementId } private fun validateEstimatedIncome(estimatedIncome: EstimatedIncome?): Boolean = @@ -207,9 +205,11 @@ sealed class IncomeStatement(val type: IncomeStatementType) { abstract val lastName: String abstract val startDate: LocalDate abstract val endDate: LocalDate? - abstract val created: HelsinkiDateTime - abstract val updated: HelsinkiDateTime - abstract val handled: Boolean + abstract val createdAt: HelsinkiDateTime + abstract val modifiedAt: HelsinkiDateTime + abstract val sentAt: HelsinkiDateTime? + abstract val handledAt: HelsinkiDateTime? + abstract val status: IncomeStatementStatus abstract val handlerNote: String @JsonTypeName("HIGHEST_FEE") @@ -220,9 +220,11 @@ sealed class IncomeStatement(val type: IncomeStatementType) { override val lastName: String, override val startDate: LocalDate, override val endDate: LocalDate?, - override val created: HelsinkiDateTime, - override val updated: HelsinkiDateTime, - override val handled: Boolean, + override val createdAt: HelsinkiDateTime, + override val modifiedAt: HelsinkiDateTime, + override val sentAt: HelsinkiDateTime?, + override val handledAt: HelsinkiDateTime?, + override val status: IncomeStatementStatus, override val handlerNote: String, ) : IncomeStatement(IncomeStatementType.HIGHEST_FEE) @@ -239,9 +241,11 @@ sealed class IncomeStatement(val type: IncomeStatementType) { val student: Boolean, val alimonyPayer: Boolean, val otherInfo: String, - override val created: HelsinkiDateTime, - override val updated: HelsinkiDateTime, - override val handled: Boolean, + override val createdAt: HelsinkiDateTime, + override val modifiedAt: HelsinkiDateTime, + override val sentAt: HelsinkiDateTime?, + override val handledAt: HelsinkiDateTime?, + override val status: IncomeStatementStatus, override val handlerNote: String, val attachments: List, ) : IncomeStatement(IncomeStatementType.INCOME) @@ -255,9 +259,11 @@ sealed class IncomeStatement(val type: IncomeStatementType) { override val startDate: LocalDate, override val endDate: LocalDate?, val otherInfo: String, - override val created: HelsinkiDateTime, - override val updated: HelsinkiDateTime, - override val handled: Boolean, + override val createdAt: HelsinkiDateTime, + override val modifiedAt: HelsinkiDateTime, + override val sentAt: HelsinkiDateTime?, + override val handledAt: HelsinkiDateTime?, + override val status: IncomeStatementStatus, override val handlerNote: String, val attachments: List, ) : IncomeStatement(IncomeStatementType.CHILD_INCOME) diff --git a/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementController.kt b/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementController.kt index 07764c17187..d7c39b02b2e 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementController.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementController.kt @@ -109,9 +109,11 @@ class IncomeStatementController(private val accessControl: AccessControl) { incomeStatementId, ) tx.updateIncomeStatementHandled( + user, + clock.now(), incomeStatementId, body.handlerNote, - if (body.handled) user.id else null, + body.handled, ) } } @@ -143,7 +145,7 @@ class IncomeStatementController(private val accessControl: AccessControl) { body.placementValidDate, body.page, pageSize = 50, - body.sortBy ?: IncomeStatementSortParam.CREATED, + body.sortBy ?: IncomeStatementSortParam.SENT_AT, body.sortDirection ?: SortDirection.ASC, ) } @@ -192,7 +194,7 @@ data class SearchIncomeStatementsRequest( ) enum class IncomeStatementSortParam { - CREATED, + SENT_AT, START_DATE, INCOME_END_DATE, TYPE, diff --git a/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerCitizen.kt b/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerCitizen.kt index 627cda29f22..61cb0712393 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerCitizen.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementControllerCitizen.kt @@ -211,19 +211,27 @@ class IncomeStatementControllerCitizen(private val accessControl: AccessControl) user: AuthenticatedUser.Citizen, clock: EvakaClock, @RequestBody body: IncomeStatementBody, + @RequestParam draft: Boolean?, ) { val id = db.connect { dbc -> - dbc.read { + dbc.transaction { tx -> accessControl.requirePermissionFor( - it, + tx, user, clock, Action.Citizen.Person.CREATE_INCOME_STATEMENT, user.id, ) + createValidatedIncomeStatement( + tx = tx, + user = user, + now = clock.now(), + personId = user.id, + body = body, + draft = draft ?: false, + ) } - createIncomeStatement(dbc, user.id, user, body) } Audit.IncomeStatementCreate.log(targetId = AuditId(user.id), objectId = AuditId(id)) } @@ -235,19 +243,27 @@ class IncomeStatementControllerCitizen(private val accessControl: AccessControl) clock: EvakaClock, @PathVariable childId: ChildId, @RequestBody body: IncomeStatementBody, + @RequestParam draft: Boolean?, ) { val id = db.connect { dbc -> - dbc.read { + dbc.transaction { tx -> accessControl.requirePermissionFor( - it, + tx, user, clock, Action.Citizen.Child.CREATE_INCOME_STATEMENT, childId, ) + createValidatedIncomeStatement( + tx = tx, + user = user, + now = clock.now(), + personId = childId, + body = body, + draft = draft ?: false, + ) } - createIncomeStatement(dbc, childId, user, body) } Audit.IncomeStatementCreateForChild.log(targetId = AuditId(user.id), objectId = AuditId(id)) } @@ -259,35 +275,38 @@ class IncomeStatementControllerCitizen(private val accessControl: AccessControl) clock: EvakaClock, @PathVariable incomeStatementId: IncomeStatementId, @RequestBody body: IncomeStatementBody, + @RequestParam draft: Boolean?, ) { - if (!validateIncomeStatementBody(body)) throw BadRequest("Invalid income statement body") + if (draft == false && !validateIncomeStatementBody(body)) + throw BadRequest("Invalid income statement body") + db.connect { dbc -> dbc.transaction { tx -> - accessControl.requirePermissionFor( - tx, - user, - clock, - Action.Citizen.IncomeStatement.UPDATE, - incomeStatementId, - ) - verifyIncomeStatementModificationsAllowed(tx, user.id, incomeStatementId) - tx.updateIncomeStatement(incomeStatementId, body).also { success -> - if (success) { - val parent = AttachmentParent.IncomeStatement(incomeStatementId) - tx.dissociateAttachmentsOfParent(user.evakaUserId, parent) - when (body) { - is IncomeStatementBody.Income -> - tx.associateOrphanAttachments( - user.evakaUserId, - parent, - body.attachmentIds, - ) - else -> Unit - } - } - } + accessControl.requirePermissionFor( + tx, + user, + clock, + Action.Citizen.IncomeStatement.UPDATE, + incomeStatementId, + ) + verifyIncomeStatementModificationsAllowed(tx, user.id, incomeStatementId) + + tx.updateIncomeStatement( + user.evakaUserId, + clock.now(), + incomeStatementId, + body, + draft ?: false, + ) + + val parent = AttachmentParent.IncomeStatement(incomeStatementId) + tx.dissociateAttachmentsOfParent(user.evakaUserId, parent) + when (body) { + is IncomeStatementBody.Income -> + tx.associateOrphanAttachments(user.evakaUserId, parent, body.attachmentIds) + else -> Unit } - .let { success -> if (!success) throw NotFound("Income statement not found") } + } } Audit.IncomeStatementUpdate.log(targetId = AuditId(incomeStatementId)) } @@ -300,41 +319,38 @@ class IncomeStatementControllerCitizen(private val accessControl: AccessControl) @PathVariable childId: ChildId, @PathVariable incomeStatementId: IncomeStatementId, @RequestBody body: IncomeStatementBody, + @RequestParam draft: Boolean?, ) { - if (!validateIncomeStatementBody(body)) + if (draft == false && !validateIncomeStatementBody(body)) throw BadRequest("Invalid child income statement body") db.connect { dbc -> dbc.transaction { tx -> - accessControl.requirePermissionFor( - tx, - user, - clock, - Action.Citizen.IncomeStatement.UPDATE, - incomeStatementId, - ) - verifyIncomeStatementModificationsAllowed( - tx, - PersonId(childId.raw), - incomeStatementId, - ) - tx.updateIncomeStatement(incomeStatementId, body).also { success -> - if (success) { - val parent = AttachmentParent.IncomeStatement(incomeStatementId) - tx.dissociateAttachmentsOfParent(user.evakaUserId, parent) - when (body) { - is IncomeStatementBody.ChildIncome -> - tx.associateOrphanAttachments( - user.evakaUserId, - parent, - body.attachmentIds, - ) - else -> Unit - } - } - } + accessControl.requirePermissionFor( + tx, + user, + clock, + Action.Citizen.IncomeStatement.UPDATE, + incomeStatementId, + ) + verifyIncomeStatementModificationsAllowed(tx, childId, incomeStatementId) + + tx.updateIncomeStatement( + user.evakaUserId, + clock.now(), + incomeStatementId, + body, + draft ?: false, + ) + + val parent = AttachmentParent.IncomeStatement(incomeStatementId) + tx.dissociateAttachmentsOfParent(user.evakaUserId, parent) + when (body) { + is IncomeStatementBody.ChildIncome -> + tx.associateOrphanAttachments(user.evakaUserId, parent, body.attachmentIds) + else -> Unit } - .let { success -> if (!success) throw NotFound("Income statement not found") } + } } Audit.IncomeStatementUpdateForChild.log(targetId = AuditId(incomeStatementId)) } @@ -421,7 +437,7 @@ class IncomeStatementControllerCitizen(private val accessControl: AccessControl) val incomeStatement = tx.readIncomeStatementForPerson(personId, id, includeEmployeeContent = false) ?: throw NotFound("Income statement not found") - if (incomeStatement.handled) { + if (incomeStatement.status == IncomeStatementStatus.HANDLED) { throw Forbidden("Handled income statement cannot be modified or removed") } } diff --git a/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementQueries.kt index c73628226c5..8fb464778c0 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementQueries.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/incomestatement/IncomeStatementQueries.kt @@ -9,9 +9,10 @@ import fi.espoo.evaka.invoicing.controller.SortDirection import fi.espoo.evaka.placement.PlacementType import fi.espoo.evaka.shared.ChildId import fi.espoo.evaka.shared.DaycareId -import fi.espoo.evaka.shared.EmployeeId +import fi.espoo.evaka.shared.EvakaUserId import fi.espoo.evaka.shared.IncomeStatementId import fi.espoo.evaka.shared.PersonId +import fi.espoo.evaka.shared.auth.AuthenticatedUser import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.db.DatabaseEnum import fi.espoo.evaka.shared.db.PredicateSql @@ -22,6 +23,14 @@ import fi.espoo.evaka.shared.mapToPaged import fi.espoo.evaka.shared.pagedForPageSize import java.time.LocalDate +enum class IncomeStatementStatus : DatabaseEnum { + DRAFT, + SENT, + HANDLED; + + override val sqlType: String = "income_statement_status" +} + enum class IncomeStatementType : DatabaseEnum { HIGHEST_FEE, INCOME, @@ -64,9 +73,11 @@ SELECT student, alimony_payer, other_info, - ist.created, - ist.updated, - handler_id IS NOT NULL AS handled, + ist.created_at, + ist.modified_at, + ist.sent_at, + ist.handled_at, + status, handler_note, (SELECT coalesce(jsonb_agg(jsonb_build_object( 'id', id, @@ -96,9 +107,11 @@ private fun Row.mapIncomeStatement(includeEmployeeContent: Boolean): IncomeState val lastName = column("last_name") val startDate = column("start_date") val endDate = column("end_date") - val created = column("created") - val updated = column("updated") - val handled = column("handled") + val createdAt = column("created_at") + val modifiedAt = column("modified_at") + val sentAt = column("sent_at") + val handledAt = column("handled_at") + val status = column("status") val handlerNote = if (includeEmployeeContent) column("handler_note") else "" return when (column("type")) { IncomeStatementType.HIGHEST_FEE -> @@ -109,9 +122,11 @@ private fun Row.mapIncomeStatement(includeEmployeeContent: Boolean): IncomeState lastName = lastName, startDate = startDate, endDate = endDate, - created = created, - updated = updated, - handled = handled, + createdAt = createdAt, + modifiedAt = modifiedAt, + sentAt = sentAt, + handledAt = handledAt, + status = status, handlerNote = handlerNote, ) IncomeStatementType.INCOME -> { @@ -201,9 +216,11 @@ private fun Row.mapIncomeStatement(includeEmployeeContent: Boolean): IncomeState student = column("student"), alimonyPayer = column("alimony_payer"), otherInfo = column("other_info"), - created = created, - updated = updated, - handled = handled, + createdAt = createdAt, + modifiedAt = modifiedAt, + sentAt = sentAt, + handledAt = handledAt, + status = status, handlerNote = handlerNote, attachments = jsonColumn("attachments"), ) @@ -216,9 +233,11 @@ private fun Row.mapIncomeStatement(includeEmployeeContent: Boolean): IncomeState lastName = lastName, startDate = startDate, endDate = endDate, - created = created, - updated = updated, - handled = handled, + createdAt = createdAt, + modifiedAt = modifiedAt, + sentAt = sentAt, + handledAt = handledAt, + status = status, handlerNote = handlerNote, otherInfo = column("other_info"), attachments = jsonColumn("attachments"), @@ -340,14 +359,23 @@ private fun Database.SqlStatement<*>.bindAccountant(accountant: Accountant) { this.bind("accountantEmail", accountant.email) } -fun Database.Transaction.createIncomeStatement( - personId: PersonId, +fun Database.Transaction.insertIncomeStatement( + userId: EvakaUserId, + now: HelsinkiDateTime, + personId: PersonId, // may be either the user or their child body: IncomeStatementBody, + draft: Boolean, ): IncomeStatementId { @Suppress("DEPRECATION") return createQuery( """ INSERT INTO income_statement ( + created_at, + created_by, + modified_at, + modified_by, + status, + sent_at, person_id, start_date, end_date, @@ -376,6 +404,12 @@ INSERT INTO income_statement ( alimony_payer, other_info ) VALUES ( + :now, + :userId, + :now, + :userId, + :status, + :sentAt, :personId, :startDate, :endDate, @@ -406,24 +440,33 @@ INSERT INTO income_statement ( ) RETURNING id """ - .trimIndent() ) + .bind("now", now) + .bind("userId", userId) .bind("personId", personId) + .bind("status", if (draft) IncomeStatementStatus.DRAFT else IncomeStatementStatus.SENT) + .bind("sentAt", if (draft) null else now) .also { it.bindIncomeStatementBody(body) } .exactlyOne() } fun Database.Transaction.updateIncomeStatement( + userId: EvakaUserId, + now: HelsinkiDateTime, incomeStatementId: IncomeStatementId, body: IncomeStatementBody, -): Boolean { - val rowCount = - @Suppress("DEPRECATION") - createUpdate( - """ + draft: Boolean, +) { + @Suppress("DEPRECATION") + createUpdate( + """ UPDATE income_statement SET + modified_at = :now, + modified_by = :userId, start_date = :startDate, end_date = :endDate, + status = CASE WHEN status = 'DRAFT'::income_statement_status THEN :status ELSE status END, + sent_at = coalesce(sent_at, :sentAt), type = :type, gross_income_source = :grossIncomeSource, gross_estimated_monthly_income = :grossEstimatedMonthlyIncome, @@ -450,28 +493,37 @@ UPDATE income_statement SET other_info = :otherInfo WHERE id = :id """ - .trimIndent() - ) - .bind("id", incomeStatementId) - .also { it.bindIncomeStatementBody(body) } - .execute() - - return rowCount == 1 + ) + .bind("id", incomeStatementId) + .bind("now", now) + .bind("userId", userId) + .bind("status", if (draft) IncomeStatementStatus.DRAFT else IncomeStatementStatus.SENT) + .bind("sentAt", if (draft) null else now) + .also { it.bindIncomeStatementBody(body) } + .updateExactlyOne() } fun Database.Transaction.updateIncomeStatementHandled( + user: AuthenticatedUser.Employee, + now: HelsinkiDateTime, incomeStatementId: IncomeStatementId, note: String, - handlerId: EmployeeId?, + handled: Boolean, ) { - @Suppress("DEPRECATION") - createUpdate( - "UPDATE income_statement SET handler_id = :handlerId, handler_note = :note WHERE id = :id" + execute { + sql( + """ +UPDATE income_statement +SET modified_at = ${bind(now)}, + modified_by = ${bind(user.evakaUserId)}, + handler_note = ${bind(note)}, + handler_id = ${bind(user.id.takeIf { handled })}, + handled_at = ${bind(now.takeIf { handled })}, + status = ${bind(if (handled) IncomeStatementStatus.HANDLED else IncomeStatementStatus.SENT)} +WHERE id = ${bind(incomeStatementId)} +""" ) - .bind("id", incomeStatementId) - .bind("note", note) - .bind("handlerId", handlerId) - .execute() + } } fun Database.Transaction.removeIncomeStatement(id: IncomeStatementId) { @@ -485,7 +537,7 @@ fun Database.Transaction.removeIncomeStatement(id: IncomeStatementId) { data class IncomeStatementAwaitingHandler( val id: IncomeStatementId, - val created: HelsinkiDateTime, + val sentAt: HelsinkiDateTime, val startDate: LocalDate, val incomeEndDate: LocalDate?, val handlerNote: String, @@ -525,10 +577,10 @@ private fun awaitingHandlerQuery( sql( """ -SELECT DISTINCT ON (created, start_date, income_end_date, type, handler_note, last_name, first_name, id) +SELECT DISTINCT ON (sent_at, start_date, income_end_date, type, handler_note, last_name, first_name, id) i.id, i.type, - i.created, + i.sent_at, i.start_date, i.handler_note, person.id AS personId, @@ -585,8 +637,8 @@ LEFT JOIN daycare d ON d.id IN ( ) LEFT JOIN care_area ca ON ca.id = d.care_area_id WHERE - handler_id IS NULL AND - between_start_and_end(tstzrange(${bind(sentStart)}, ${bind(sentEnd)}, '[)'), i.created) AND + i.status = 'SENT'::income_statement_status AND + between_start_and_end(tstzrange(${bind(sentStart)}, ${bind(sentEnd)}, '[)'), i.sent_at) AND ${predicate(filters)} """ ) @@ -624,18 +676,18 @@ fun Database.Read.fetchIncomeStatementsAwaitingHandler( val count = createQuery { sql("SELECT COUNT(*) FROM (${subquery(query)}) q") }.exactlyOne() val sortColumn = when (sortBy) { - IncomeStatementSortParam.CREATED -> - "i.created ${sortDirection.name}, i.start_date, income_end_date, i.type, i.handler_note, person.last_name, person.first_name" + IncomeStatementSortParam.SENT_AT -> + "i.sent_at ${sortDirection.name}, i.start_date, income_end_date, i.type, i.handler_note, person.last_name, person.first_name" IncomeStatementSortParam.START_DATE -> - "i.start_date ${sortDirection.name}, i.created, income_end_date, i.type, i.handler_note, person.last_name, person.first_name" + "i.start_date ${sortDirection.name}, i.sent_at, income_end_date, i.type, i.handler_note, person.last_name, person.first_name" IncomeStatementSortParam.INCOME_END_DATE -> - "income_end_date ${sortDirection.name}, i.created, i.start_date, i.type, i.handler_note, person.last_name, person.first_name" + "income_end_date ${sortDirection.name}, i.sent_at, i.start_date, i.type, i.handler_note, person.last_name, person.first_name" IncomeStatementSortParam.TYPE -> - "i.type ${sortDirection.name}, i.created, i.start_date, income_end_date, i.handler_note, person.last_name, person.first_name" + "i.type ${sortDirection.name}, i.sent_at, i.start_date, income_end_date, i.handler_note, person.last_name, person.first_name" IncomeStatementSortParam.HANDLER_NOTE -> - "i.handler_note ${sortDirection.name}, i.created, i.start_date, income_end_date, i.type, person.last_name, person.first_name" + "i.handler_note ${sortDirection.name}, i.sent_at, i.start_date, income_end_date, i.type, person.last_name, person.first_name" IncomeStatementSortParam.PERSON_NAME -> - "person.last_name ${sortDirection.name}, person.first_name ${sortDirection.name}, i.created, i.start_date, income_end_date, i.type, i.handler_note" + "person.last_name ${sortDirection.name}, person.first_name ${sortDirection.name}, i.sent_at, i.start_date, income_end_date, i.type, i.handler_note" } val rows = createQuery { diff --git a/service/src/main/kotlin/fi/espoo/evaka/invoicing/data/FeeDecisionQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/invoicing/data/FeeDecisionQueries.kt index 59b9e621a6b..5ceb82293c8 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/invoicing/data/FeeDecisionQueries.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/invoicing/data/FeeDecisionQueries.kt @@ -472,7 +472,7 @@ fun Database.Read.searchFeeDecisions( SELECT FROM income_statement WHERE person_id IN (decision.head_of_family_id, decision.partner_id, part.child_id) AND daterange(start_date, end_date, '[]') && daterange((:now - interval '14 months')::date, :now::date, '[]') AND - handler_id IS NULL + status = 'SENT' ) """ else null, diff --git a/service/src/main/kotlin/fi/espoo/evaka/invoicing/service/IncomeQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/invoicing/service/IncomeQueries.kt index 900eb8ebbda..6d0aa399fd9 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/invoicing/service/IncomeQueries.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/invoicing/service/IncomeQueries.kt @@ -81,8 +81,8 @@ FROM expiring_income_with_billable_placement_day_after_expiration expiring_incom WHERE NOT EXISTS ( SELECT 1 FROM income_statement WHERE person_id = expiring_income.person_id - AND handler_id IS NULL - AND created > ${bind(today)} - INTERVAL '12 months' + AND status = 'SENT'::income_statement_status + AND sent_at > ${bind(today)} - INTERVAL '12 months' AND (end_date IS NULL OR ${bind(dayAfterExpiration)} <= end_date) ) diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DataInitializers.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DataInitializers.kt index f65ed684c0f..f00900b72cd 100755 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DataInitializers.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DataInitializers.kt @@ -15,7 +15,9 @@ import fi.espoo.evaka.daycare.ClubTerm import fi.espoo.evaka.decision.DecisionStatus import fi.espoo.evaka.decision.DecisionType import fi.espoo.evaka.identity.ExternalId -import fi.espoo.evaka.incomestatement.IncomeStatementType +import fi.espoo.evaka.incomestatement.IncomeStatementBody +import fi.espoo.evaka.incomestatement.IncomeStatementStatus +import fi.espoo.evaka.incomestatement.insertIncomeStatement import fi.espoo.evaka.invoicing.domain.FeeAlterationType import fi.espoo.evaka.invoicing.domain.FeeThresholds import fi.espoo.evaka.invoicing.domain.IncomeEffect @@ -533,25 +535,45 @@ VALUES (${bind(row.id)}, ${bind(row.personId)}, ${bind(row.validFrom)}, ${bind(r data class DevIncomeStatement( val id: IncomeStatementId = IncomeStatementId(UUID.randomUUID()), + val createdAt: HelsinkiDateTime = HelsinkiDateTime.now(), + val createdBy: EvakaUserId = AuthenticatedUser.SystemInternalUser.evakaUserId, + val modifiedAt: HelsinkiDateTime = HelsinkiDateTime.now(), + val modifiedBy: EvakaUserId = AuthenticatedUser.SystemInternalUser.evakaUserId, val personId: PersonId, - val startDate: LocalDate, - val type: IncomeStatementType, - val grossEstimatedMonthlyIncome: Int, + val data: IncomeStatementBody, + val status: IncomeStatementStatus = IncomeStatementStatus.SENT, + val sentAt: HelsinkiDateTime? = HelsinkiDateTime.now(), val handlerId: EmployeeId? = null, + val handledAt: HelsinkiDateTime? = null, ) -fun Database.Transaction.insert(row: DevIncomeStatement): IncomeStatementId = - createUpdate { - sql( - """ -INSERT INTO income_statement (id, person_id, start_date, type, gross_estimated_monthly_income, handler_id) -VALUES (${bind(row.id)}, ${bind(row.personId)}, ${bind(row.startDate)}, ${bind(row.type)}, ${bind(row.grossEstimatedMonthlyIncome)}, ${bind(row.handlerId)}) -RETURNING id -""" - ) - } - .executeAndReturnGeneratedKeys() - .exactlyOne() +fun Database.Transaction.insert(row: DevIncomeStatement): IncomeStatementId { + // insertion has complex bind logic, so workaround for reusing that + val databaseGeneratedId = + insertIncomeStatement( + userId = row.createdBy, + now = row.createdAt, + personId = row.personId, + body = row.data, + draft = row.status == IncomeStatementStatus.DRAFT, + ) + execute { + sql( + """ + UPDATE income_statement + SET id = ${bind(row.id)}, + modified_at = ${bind(row.modifiedAt)}, + modified_by = ${bind(row.modifiedBy)}, + status = ${bind(row.status)}, + sent_at = ${bind(row.sentAt)}, + handler_id = ${bind(row.handlerId)}, + handled_at = ${bind(row.handledAt)} + WHERE id = ${bind(databaseGeneratedId)} + """ + ) + } + return row.id +} data class DevFeeAlteration( val id: FeeAlterationId = FeeAlterationId(UUID.randomUUID()), diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt index 93de5147ab1..16a04dcddc8 100755 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt @@ -66,8 +66,6 @@ import fi.espoo.evaka.holidayperiod.createFixedPeriodQuestionnaire import fi.espoo.evaka.holidayperiod.insertHolidayPeriod import fi.espoo.evaka.identity.ExternalId import fi.espoo.evaka.identity.ExternalIdentifier -import fi.espoo.evaka.incomestatement.IncomeStatementBody -import fi.espoo.evaka.incomestatement.createIncomeStatement import fi.espoo.evaka.invoicing.data.markVoucherValueDecisionsSent import fi.espoo.evaka.invoicing.data.updateFeeDecisionDocumentKey import fi.espoo.evaka.invoicing.data.updateVoucherValueDecisionDocumentKey @@ -577,18 +575,10 @@ UPDATE placement SET end_date = ${bind(req.endDate)}, termination_requested_date @RequestBody feeThresholds: FeeThresholds, ): FeeThresholdsId = db.connect { dbc -> dbc.transaction { it.insert(feeThresholds) } } - data class DevCreateIncomeStatements( - val personId: PersonId, - val data: List, - ) - - @PostMapping("/income-statements") - fun createIncomeStatements(db: Database, @RequestBody body: DevCreateIncomeStatements) = - db.connect { dbc -> - dbc.transaction { tx -> - body.data.forEach { tx.createIncomeStatement(body.personId, it) } - } - } + @PostMapping("/income-statement") + fun createIncomeStatement(db: Database, @RequestBody body: DevIncomeStatement) { + db.connect { dbc -> dbc.transaction { it.insert(body) } } + } @PostMapping("/income") fun createIncome(db: Database, @RequestBody body: DevIncome) { diff --git a/service/src/main/resources/db/migration/V469__income_statement_status.sql b/service/src/main/resources/db/migration/V469__income_statement_status.sql new file mode 100644 index 00000000000..487e0558cf0 --- /dev/null +++ b/service/src/main/resources/db/migration/V469__income_statement_status.sql @@ -0,0 +1,61 @@ +CREATE TYPE income_statement_status AS ENUM ('DRAFT', 'SENT', 'HANDLED'); + +ALTER TABLE income_statement RENAME COLUMN created to created_at; + +DROP TRIGGER set_timestamp ON income_statement; +ALTER TABLE income_statement RENAME COLUMN updated to updated_at; +CREATE TRIGGER set_timestamp BEFORE UPDATE ON income_statement + FOR EACH ROW EXECUTE PROCEDURE trigger_refresh_updated_at(); + +ALTER TABLE income_statement + ADD COLUMN status income_statement_status, + -- created_at exists + ADD COLUMN created_by uuid REFERENCES evaka_user, + ADD COLUMN modified_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN modified_by uuid REFERENCES evaka_user, + ADD COLUMN sent_at TIMESTAMP WITH TIME ZONE, + -- sent_by = created_by + ADD COLUMN handled_at TIMESTAMP WITH TIME ZONE + -- handled_by = handler_id +; + +UPDATE income_statement SET + status = CASE + WHEN handler_id IS NOT NULL + THEN 'HANDLED'::income_statement_status + ELSE 'SENT'::income_statement_status + END, + -- person_id may also refer to child so created_by/modified_by is not reliably known + created_by = '00000000-0000-0000-0000-000000000000'::UUID, + modified_by = '00000000-0000-0000-0000-000000000000'::UUID, + modified_at = updated_at, + sent_at = created_at, + handled_at = CASE + WHEN handler_id IS NOT NULL + THEN updated_at -- best guess + END +; + +ALTER TABLE income_statement + ALTER COLUMN status SET NOT NULL, + ALTER COLUMN created_by SET NOT NULL, + ALTER COLUMN modified_at SET NOT NULL, + ALTER COLUMN modified_by SET NOT NULL; + +ALTER TABLE income_statement + ADD CONSTRAINT income_statement_status_check CHECK ( + (status = 'HANDLED'::income_statement_status) = (handler_id IS NOT NULL) AND + (status = 'HANDLED'::income_statement_status) = (handled_at IS NOT NULL) AND + (status = 'DRAFT'::income_statement_status) = (sent_at IS NULL) + ); + +DROP INDEX idx$income_statement_created_handler_id_null; +CREATE INDEX idx$income_statement_sent_at_awaiting_handler + ON income_statement (sent_at) + WHERE (status = 'SENT'::income_statement_status); + +CREATE INDEX idx$income_statement_created_by ON income_statement (created_by); +CREATE INDEX idx$income_statement_modified_by ON income_statement (modified_by); + +-- index that appears to be unused +DROP INDEX idx$income_statement_start_date_handler_id_null; diff --git a/service/src/main/resources/migrations.txt b/service/src/main/resources/migrations.txt index ce93570bb50..19efde44854 100644 --- a/service/src/main/resources/migrations.txt +++ b/service/src/main/resources/migrations.txt @@ -464,3 +464,4 @@ V465__invoice_replacement_info.sql V466__push_notification_calendar_event_reservation.sql V467__push_notification_calendar_event_reservation_defaults.sql V468__citizen_user.sql +V469__income_statement_status.sql