diff --git a/src/back/routes/update.ts b/src/back/routes/update.ts index a181c6943..f9443fa91 100644 --- a/src/back/routes/update.ts +++ b/src/back/routes/update.ts @@ -9,25 +9,32 @@ import { Request } from 'express' import ProposalModel from '../../entities/Proposal/model' import { ProposalAttributes } from '../../entities/Proposal/types' import { + FinancialRecord, FinancialUpdateSectionSchema, - GeneralUpdateSection, GeneralUpdateSectionSchema, + UpdateGeneralSection, } from '../../entities/Updates/types' import { getCurrentUpdate, + getFundsReleasedSinceLatestUpdate, + getLatestUpdate, getNextPendingUpdate, getPendingUpdates, getPublicUpdates, + getReleases, isBetweenLateThresholdDate, } from '../../entities/Updates/utils' import { DiscourseService } from '../../services/DiscourseService' import { ErrorService } from '../../services/ErrorService' import { FinancialService } from '../../services/FinancialService' +import { VestingService } from '../../services/VestingService' import Time from '../../utils/date/Time' import { ErrorCategory } from '../../utils/errorCategories' import { CoauthorService } from '../services/coauthor' import { UpdateService } from '../services/update' +import { getProposal } from './proposal' + export default routes((route) => { const withAuth = auth() route.get('/proposals/:proposal/updates', handleAPI(getProposalUpdates)) @@ -103,12 +110,8 @@ async function getProposalUpdateComments(req: Request<{ update_id: string }>) { } const generalSectionValidator = schema.compile(GeneralUpdateSectionSchema) -async function createProposalUpdate(req: WithAuth>) { - const { author, financial_records, ...body } = req.body - const { health, introduction, highlights, blockers, next_steps, additional_notes } = validate( - generalSectionValidator, - body - ) + +function parseFinancialRecords(financial_records: unknown) { const parsedResult = FinancialUpdateSectionSchema.safeParse({ financial_records }) if (!parsedResult.success) { ErrorService.report('Submission of invalid financial records', { @@ -117,27 +120,60 @@ async function createProposalUpdate(req: WithAuth> }) throw new RequestError(`Invalid financial records`, RequestError.BadRequest) } - const parsedRecords = parsedResult.data.financial_records + return parsedResult.data.financial_records +} - return await UpdateService.create( - { - proposal_id: req.params.proposal, - author, - health, - introduction, - highlights, - blockers, - next_steps, - additional_notes, - financial_records: parsedRecords, - }, - req.auth! +async function validateFinancialRecords( + proposal: ProposalAttributes, + financial_records: unknown +): Promise { + const [vestingData, updates] = await Promise.all([ + VestingService.getVestingInfo(proposal.vesting_addresses), + UpdateService.getAllByProposalId(proposal.id), + ]) + + const releases = vestingData ? getReleases(vestingData) : undefined + const publicUpdates = getPublicUpdates(updates) + const latestUpdate = getLatestUpdate(publicUpdates || []) + const { releasedFunds } = getFundsReleasedSinceLatestUpdate(latestUpdate, releases) + return releasedFunds > 0 ? parseFinancialRecords(financial_records) : null +} + +async function createProposalUpdate(req: WithAuth>) { + const { author, financial_records, ...body } = req.body + const { health, introduction, highlights, blockers, next_steps, additional_notes } = validate( + generalSectionValidator, + body ) + try { + const proposal = await getProposal(req) + const financialRecords = await validateFinancialRecords(proposal, financial_records) + return await UpdateService.create( + { + proposal_id: req.params.proposal, + author, + health, + introduction, + highlights, + blockers, + next_steps, + additional_notes, + financial_records: financialRecords, + }, + req.auth! + ) + } catch (error) { + ErrorService.report('Error creating update', { + error, + category: ErrorCategory.Update, + }) + throw new RequestError(`Something wnt wrong: ${error}`, RequestError.InternalServerError) + } } async function updateProposalUpdate(req: WithAuth>) { const { id, author, financial_records, ...body } = req.body - const { health, introduction, highlights, blockers, next_steps, additional_notes } = validate( + const { health, introduction, highlights, blockers, next_steps, additional_notes } = validate( generalSectionValidator, body ) diff --git a/src/back/services/update.ts b/src/back/services/update.ts index 2172eedd1..302336d31 100644 --- a/src/back/services/update.ts +++ b/src/back/services/update.ts @@ -162,7 +162,7 @@ export class UpdateService { } const update = await UpdateModel.createUpdate(data) try { - await FinancialService.createRecords(update.id, financial_records || []) + if (financial_records) await FinancialService.createRecords(update.id, financial_records) await DiscourseService.createUpdate(update, proposal.title) await EventsService.updateCreated(update.id, proposal.id, proposal.title, user) DiscordService.newUpdate(proposal.id, proposal.title, update.id, user) @@ -190,7 +190,8 @@ export class UpdateService { const { author, health, introduction, highlights, blockers, next_steps, additional_notes, financial_records } = newUpdate - await FinancialService.createRecords(update.id, financial_records || update.financial_records!) + const records = financial_records || update.financial_records + if (records) await FinancialService.createRecords(update.id, records) await UpdateModel.update( { diff --git a/src/clients/Governance.ts b/src/clients/Governance.ts index e768ee187..7e0bd7f6d 100644 --- a/src/clients/Governance.ts +++ b/src/clients/Governance.ts @@ -34,9 +34,9 @@ import { QuarterBudgetAttributes } from '../entities/QuarterBudget/types' import { SubscriptionAttributes } from '../entities/Subscription/types' import { Topic } from '../entities/SurveyTopic/types' import { - FinancialUpdateSection, - GeneralUpdateSection, UpdateAttributes, + UpdateFinancialSection, + UpdateGeneralSection, UpdateResponse, UpdateSubmissionDetails, } from '../entities/Updates/types' @@ -259,7 +259,7 @@ export class Governance extends API { async createProposalUpdate( proposal_id: string, - update: UpdateSubmissionDetails & GeneralUpdateSection & FinancialUpdateSection + update: UpdateSubmissionDetails & UpdateGeneralSection & UpdateFinancialSection ) { return await this.fetchApiResponse(`/proposals/${proposal_id}/update`, { method: 'POST', @@ -270,7 +270,7 @@ export class Governance extends API { async updateProposalUpdate( proposal_id: string, - update: UpdateSubmissionDetails & GeneralUpdateSection & FinancialUpdateSection + update: UpdateSubmissionDetails & UpdateGeneralSection & UpdateFinancialSection ) { return await this.fetchApiResponse(`/proposals/${proposal_id}/update`, { method: 'PATCH', diff --git a/src/components/Updates/FinancialCardsSection.tsx b/src/components/Updates/FinancialCardsSection.tsx index 34a76def9..4405f474e 100644 --- a/src/components/Updates/FinancialCardsSection.tsx +++ b/src/components/Updates/FinancialCardsSection.tsx @@ -1,8 +1,5 @@ import { useIntl } from 'react-intl' -import { VestingLog } from '../../clients/VestingData' -import { UpdateAttributes } from '../../entities/Updates/types' -import { getFundsReleasedSinceLatestUpdate } from '../../entities/Updates/utils' import { CURRENCY_FORMAT_OPTIONS } from '../../helpers' import useFormatMessage from '../../hooks/useFormatMessage' import { formatDate } from '../../utils/date/Time' @@ -11,20 +8,21 @@ import FinancialCard from './FinancialCard' import './FinancialCardsSection.css' interface Props { - releases?: VestingLog[] - previousUpdate?: Omit - currentUpdate?: Omit + releasedFundsValue: number + latestTimestamp?: string + txAmount: number disclosedFunds: number + undisclosedFunds: number } -function FinancialCardsSection({ releases, previousUpdate, currentUpdate, disclosedFunds }: Props) { +function FinancialCardsSection({ + releasedFundsValue, + latestTimestamp, + txAmount, + disclosedFunds, + undisclosedFunds, +}: Props) { const t = useFormatMessage() - const { - value: releasedFundsValue, - txAmount, - latestTimestamp, - } = getFundsReleasedSinceLatestUpdate(previousUpdate, releases, currentUpdate?.completion_date) - const undisclosedFunds = disclosedFunds <= releasedFundsValue ? releasedFundsValue - disclosedFunds : 0 const { formatNumber } = useIntl() return (
diff --git a/src/components/Updates/FinancialSection.tsx b/src/components/Updates/FinancialSection.tsx index 030b8e063..dda271755 100644 --- a/src/components/Updates/FinancialSection.tsx +++ b/src/components/Updates/FinancialSection.tsx @@ -2,16 +2,16 @@ import { useCallback, useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { usePapaParse } from 'react-papaparse' -import sum from 'lodash/sum' import toNumber from 'lodash/toNumber' import { VestingLog } from '../../clients/VestingData' import { FinancialRecord, - FinancialUpdateSection, FinancialUpdateSectionSchema, UpdateAttributes, + UpdateFinancialSection, } from '../../entities/Updates/types' +import { getDisclosedAndUndisclosedFunds, getFundsReleasedSinceLatestUpdate } from '../../entities/Updates/utils' import useFormatMessage from '../../hooks/useFormatMessage' import CSVDragAndDrop from '../Common/CSVDragAndDrop' import NumberedTextArea from '../Common/NumberedTextArea' @@ -25,10 +25,10 @@ import './FinancialSection.css' import SummaryItems from './SummaryItems' interface Props { - onValidation: (data: FinancialUpdateSection, sectionValid: boolean) => void + onValidation: (data: UpdateFinancialSection, sectionValid: boolean) => void isFormDisabled: boolean sectionNumber: number - intialValues?: Partial + intialValues?: Partial releases?: VestingLog[] latestUpdate?: Omit csvInputField: string | undefined @@ -40,7 +40,7 @@ type Error = { text: string } -const UPDATE_FINANCIAL_INITIAL_STATE: FinancialUpdateSection = { +const UPDATE_FINANCIAL_INITIAL_STATE: UpdateFinancialSection = { financial_records: [], } @@ -80,13 +80,19 @@ function FinancialSection({ formState: { isValid, isDirty }, setValue, watch, - } = useForm({ + } = useForm({ defaultValues: intialValues || UPDATE_FINANCIAL_INITIAL_STATE, mode: 'onTouched', }) const [errors, setErrors] = useState([]) - const financial_records = watch('financial_records') + const financialRecords: FinancialRecord[] | null = watch('financial_records') + + const { releasedFunds, releasesTxCount, latestReleaseTimestamp } = getFundsReleasedSinceLatestUpdate( + latestUpdate, + releases + ) + const { disclosedFunds, undisclosedFunds } = getDisclosedAndUndisclosedFunds(releasedFunds, financialRecords) let typingTimeout: NodeJS.Timeout | null = null @@ -119,12 +125,12 @@ function FinancialSection({ } useEffect(() => { - if (financial_records.length > 0 && errors.length === 0) { - onValidation({ financial_records: financial_records }, true) + if ((undisclosedFunds === 0 || (financialRecords && financialRecords.length > 0)) && errors.length === 0) { + onValidation({ financial_records: financialRecords }, true) } else { - onValidation({ financial_records: financial_records }, false) + onValidation({ financial_records: financialRecords }, false) } - }, [onValidation, financial_records, errors.length]) + }, [onValidation, financialRecords, undisclosedFunds, errors.length]) const csvInputHandler = useCallback( (data: string[][]) => { @@ -219,9 +225,11 @@ function FinancialSection({ > amount))} + releasedFundsValue={releasedFunds} + latestTimestamp={latestReleaseTimestamp} + txAmount={releasesTxCount} + undisclosedFunds={undisclosedFunds} + disclosedFunds={disclosedFunds} /> @@ -239,10 +247,10 @@ function FinancialSection({
- {financial_records.length > 0 && ( + {financialRecords && financialRecords.length > 0 && ( - + )} diff --git a/src/components/Updates/GeneralSection.tsx b/src/components/Updates/GeneralSection.tsx index 407f50ef2..89cf906b0 100644 --- a/src/components/Updates/GeneralSection.tsx +++ b/src/components/Updates/GeneralSection.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { useForm, useWatch } from 'react-hook-form' -import { GeneralUpdateSection, GeneralUpdateSectionSchema, ProjectHealth } from '../../entities/Updates/types' +import { GeneralUpdateSectionSchema, ProjectHealth, UpdateGeneralSection } from '../../entities/Updates/types' import useFormatMessage from '../../hooks/useFormatMessage' import Label from '../Common/Typography/Label' import MarkdownField from '../Form/MarkdownFieldSection' @@ -11,13 +11,13 @@ import ProjectRequestSection from '../ProjectRequest/ProjectRequestSection' import ProjectHealthButton from './ProjectHealthButton' interface Props { - onValidation: (data: GeneralUpdateSection, sectionValid: boolean) => void + onValidation: (data: UpdateGeneralSection, sectionValid: boolean) => void isFormDisabled: boolean sectionNumber: number - intialValues?: Partial + intialValues?: Partial } -const UPDATE_GENERAL_INITIAL_STATE: GeneralUpdateSection = { +const UPDATE_GENERAL_INITIAL_STATE: UpdateGeneralSection = { health: ProjectHealth.OnTrack, introduction: '', highlights: '', @@ -26,7 +26,7 @@ const UPDATE_GENERAL_INITIAL_STATE: GeneralUpdateSection = { additional_notes: '', } -const schema: Record> = GeneralUpdateSectionSchema.properties +const schema: Record> = GeneralUpdateSectionSchema.properties function GeneralSection({ onValidation, isFormDisabled, sectionNumber, intialValues }: Props) { const t = useFormatMessage() @@ -35,7 +35,7 @@ function GeneralSection({ onValidation, isFormDisabled, sectionNumber, intialVal control, setValue, watch, - } = useForm({ + } = useForm({ defaultValues: intialValues || UPDATE_GENERAL_INITIAL_STATE, mode: 'onTouched', }) @@ -45,7 +45,7 @@ function GeneralSection({ onValidation, isFormDisabled, sectionNumber, intialVal const handleHealthChange = (health: ProjectHealth) => setValue('health', health) const health = watch('health') - const getFieldProps = (fieldName: keyof GeneralUpdateSection, isRequired = true) => ({ + const getFieldProps = (fieldName: keyof UpdateGeneralSection, isRequired = true) => ({ control, name: fieldName, error: !!errors[fieldName], @@ -72,7 +72,7 @@ function GeneralSection({ onValidation, isFormDisabled, sectionNumber, intialVal }) useEffect(() => { - onValidation({ ...(values as GeneralUpdateSection) }, isValid) + onValidation({ ...(values as UpdateGeneralSection) }, isValid) }, [values, isValid, onValidation]) return ( (vestingData ? getReleases(vestingData) : undefined), [vestingData]) const { financial_records } = update + const { releasedFunds, releasesTxCount, latestReleaseTimestamp } = getFundsReleasedSinceLatestUpdate( + previousUpdate, + releases, + update.completion_date + ) + const { disclosedFunds, undisclosedFunds } = getDisclosedAndUndisclosedFunds(releasedFunds, financial_records) + return ( {update?.health && } @@ -64,10 +74,11 @@ const UpdateMarkdownView = ({ update, author, previousUpdate, proposal, classNam {t('page.update_detail.financial_details')}
amount))} + latestTimestamp={latestReleaseTimestamp} + txAmount={releasesTxCount} + undisclosedFunds={undisclosedFunds} + releasedFundsValue={releasedFunds} + disclosedFunds={disclosedFunds} />
diff --git a/src/entities/Updates/types.ts b/src/entities/Updates/types.ts index 2dbb24bc8..3f39d1e34 100644 --- a/src/entities/Updates/types.ts +++ b/src/entities/Updates/types.ts @@ -6,7 +6,7 @@ export type UpdateSubmissionDetails = { author: string } -export type GeneralUpdateSection = { +export type UpdateGeneralSection = { health: ProjectHealth introduction: string highlights: string @@ -17,10 +17,10 @@ export type GeneralUpdateSection = { export type FinancialRecord = z.infer -export type FinancialUpdateSection = z.infer +export type UpdateFinancialSection = z.infer -export type UpdateAttributes = Partial & - Partial & { +export type UpdateAttributes = Partial & + Partial & { id: string proposal_id: string author?: string @@ -119,5 +119,5 @@ const FinancialRecordSchema = z }) export const FinancialUpdateSectionSchema = z.object({ - financial_records: z.array(FinancialRecordSchema).min(1), + financial_records: z.array(FinancialRecordSchema).min(1).nullable(), }) diff --git a/src/entities/Updates/utils.ts b/src/entities/Updates/utils.ts index 43544af97..fd4eadb93 100644 --- a/src/entities/Updates/utils.ts +++ b/src/entities/Updates/utils.ts @@ -6,7 +6,7 @@ import { ContractVersion, TopicsByVersion } from '../../utils/contracts/vesting' import Time from '../../utils/date/Time' import { ProposalStatus } from '../Proposal/types' -import { UpdateAttributes, UpdateStatus } from './types' +import { FinancialRecord, UpdateAttributes, UpdateStatus } from './types' const TOPICS_V1 = TopicsByVersion[ContractVersion.V1] const TOPICS_V2 = TopicsByVersion[ContractVersion.V2] @@ -101,11 +101,11 @@ export function getFundsReleasedSinceLatestUpdate( latestUpdate: Omit | undefined, releases: VestingLog[] | undefined, beforeDate?: Date -): { value: number; txAmount: number; latestTimestamp?: string } { - if (!releases || releases.length === 0) return { value: 0, txAmount: 0 } +): { releasedFunds: number; releasesTxCount: number; latestReleaseTimestamp?: string } { + if (!releases || releases.length === 0) return { releasedFunds: 0, releasesTxCount: 0 } if (!latestUpdate) { - return { value: sum(releases.map(({ amount }) => amount || 0)), txAmount: releases.length } + return { releasedFunds: sum(releases.map(({ amount }) => amount || 0)), releasesTxCount: releases.length } } const { completion_date } = latestUpdate @@ -115,9 +115,9 @@ export function getFundsReleasedSinceLatestUpdate( Time(timestamp).isAfter(completion_date) && (beforeDate ? Time(timestamp).isBefore(beforeDate) : true) ) return { - value: sum(releasesSinceLatestUpdate.map(({ amount }) => amount || 0)), - txAmount: releasesSinceLatestUpdate.length, - latestTimestamp: releasesSinceLatestUpdate[0]?.timestamp, + releasedFunds: sum(releasesSinceLatestUpdate.map(({ amount }) => amount || 0)), + releasesTxCount: releasesSinceLatestUpdate.length, + latestReleaseTimestamp: releasesSinceLatestUpdate[0]?.timestamp, } } @@ -141,3 +141,12 @@ export function getLatestUpdate(publicUpdates: UpdateAttributes[], beforeDate?: return filteredUpdates.find((update) => Time(update.completion_date).isBefore(beforeDate)) } + +export function getDisclosedAndUndisclosedFunds( + releasedForThisUpdate: number, + financialRecords?: FinancialRecord[] | null +) { + const disclosedFunds = financialRecords?.reduce((acc, financialRecord) => acc + financialRecord.amount, 0) || 0 + const undisclosedFunds = disclosedFunds <= releasedForThisUpdate ? releasedForThisUpdate - disclosedFunds : 0 + return { disclosedFunds, undisclosedFunds } +} diff --git a/src/pages/submit/update.tsx b/src/pages/submit/update.tsx index f8860d328..445f00655 100644 --- a/src/pages/submit/update.tsx +++ b/src/pages/submit/update.tsx @@ -19,10 +19,10 @@ import FinancialSection from '../../components/Updates/FinancialSection' import GeneralSection from '../../components/Updates/GeneralSection' import UpdateMarkdownView from '../../components/Updates/UpdateMarkdownView' import { - FinancialUpdateSection, - GeneralUpdateSection, GeneralUpdateSectionSchema, UpdateAttributes, + UpdateFinancialSection, + UpdateGeneralSection, UpdateStatus, UpdateSubmissionDetails, } from '../../entities/Updates/types' @@ -56,8 +56,8 @@ const intialValidationState: UpdateValidationState = { financialSectionValid: false, } -const initialGeneralState: Partial | undefined = undefined -const initialFinancialState: FinancialUpdateSection | undefined = undefined +const initialGeneralState: Partial | undefined = undefined +const initialFinancialState: UpdateFinancialSection | undefined = undefined function getInitialUpdateValues( update: UpdateAttributes | null | undefined, @@ -108,7 +108,7 @@ export default function Update({ isEdit }: Props) { const releases = useMemo(() => (vestingData ? getReleases(vestingData) : undefined), [vestingData]) const handleGeneralSectionValidation = useCallback( - (data: GeneralUpdateSection, sectionValid: boolean) => { + (data: UpdateGeneralSection, sectionValid: boolean) => { patchGeneralSection((prevState) => ({ ...prevState, ...data })) patchValidationState((prevState) => ({ ...prevState, generalSectionValid: sectionValid })) }, @@ -116,7 +116,7 @@ export default function Update({ isEdit }: Props) { ) const handleFinancialSectionValidation = useCallback( - (data: FinancialUpdateSection | undefined, sectionValid: boolean) => { + (data: UpdateFinancialSection | undefined, sectionValid: boolean) => { if (data) { patchFinancialSection((prevState) => ({ ...prevState, ...data })) } @@ -136,14 +136,14 @@ export default function Update({ isEdit }: Props) { [financialSection, generalSection] ) - const submitUpdate = async (data: GeneralUpdateSection & FinancialUpdateSection) => { + const submitUpdate = async (data: UpdateGeneralSection & UpdateFinancialSection) => { if (!proposalId) { return } setFormDisabled(true) - const newUpdate: UpdateSubmissionDetails & GeneralUpdateSection & FinancialUpdateSection = { + const newUpdate: UpdateSubmissionDetails & UpdateGeneralSection & UpdateFinancialSection = { author: account!, health: data.health, introduction: data.introduction, @@ -173,7 +173,7 @@ export default function Update({ isEdit }: Props) { } } - const onSubmit: SubmitHandler = (data) => { + const onSubmit: SubmitHandler = (data) => { if (isEdit) { setIsEditModalOpen(true) } else { @@ -237,7 +237,7 @@ export default function Update({ isEdit }: Props) { isFormDisabled={formDisabled} intialValues={ generalSection || - getInitialUpdateValues( + getInitialUpdateValues( update, (key) => key in GeneralUpdateSectionSchema.properties ) @@ -251,9 +251,9 @@ export default function Update({ isEdit }: Props) { onValidation={handleFinancialSectionValidation} intialValues={ financialSection || - getInitialUpdateValues( + getInitialUpdateValues( update, - (key) => key in ({ financial_records: [] } as FinancialUpdateSection) + (key) => key in ({ financial_records: [] } as UpdateFinancialSection) ) } releases={releases} @@ -275,7 +275,7 @@ export default function Update({ isEdit }: Props) { disabled={formDisabled || !isValidToSubmit} loading={formDisabled} onClick={() => - onSubmit({ ...generalSection, ...financialSection } as GeneralUpdateSection & FinancialUpdateSection) + onSubmit({ ...generalSection, ...financialSection } as UpdateGeneralSection & UpdateFinancialSection) } > {t('page.proposal_update.publish_update')} @@ -305,7 +305,7 @@ export default function Update({ isEdit }: Props) { open={isEditModalOpen} onClose={handleEditModalClose} onClickAccept={() => - submitUpdate({ ...generalSection, ...financialSection } as GeneralUpdateSection & FinancialUpdateSection) + submitUpdate({ ...generalSection, ...financialSection } as UpdateGeneralSection & UpdateFinancialSection) } /> )}