import { Injectable } from '@angular/core'; import { CurrencyService } from '@core/services/currency.service'; import { SpinnerService } from '@core/services/spinner.service'; import { OpenCloseBudgetAPI } from '@core/typings/api/open-close-budget.typing'; import { BaseFundingSourceIntersection, Budget, BudgetDetail, BudgetDrilldownInfo, BudgetForImport, BudgetFundingSource, BudgetFundingSourceAudit, BudgetFundingSourceAuditTypes as AuditTypes, BudgetFundingSourceCombo, BudgetFundingSourceModalResponse, BudgetImport, BudgetImportModel, BudgetSave, FundingSource, FundingSourceAllocationsImport, FundingSourceDrilldownInfo, FundingSourceTypes, RemainingAmountBudgetMap, SimpleBudgetFundingSource } from '@core/typings/budget.typing'; import { ProcessingTypes } from '@core/typings/payment.typing'; import { ApplicationBudgetInfo } from '@features/budget-assignments/budget-assignments.typing'; import { ClientSettingsService } from '@features/client-settings/client-settings.service'; import { ProgramService } from '@features/programs/program.service'; import { ArrayHelpersService, createTopLevelValidator, FileService, IsNumber, OrganizationEligibleForGivingStatus, SimpleStringMap, TopLevelValidatorReturn, Transform, TypeaheadSelectOption, ValidatorReturn } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { MaskingService } from '@yourcause/common/masking'; import { NotifierService } from '@yourcause/common/notifier'; import { AttachYCState, BaseYCService } from '@yourcause/common/state'; import { BudgetResources } from './budget.resources'; import { BudgetState } from './budget.state'; @AttachYCState(BudgetState) @Injectable({ providedIn: 'root' }) export class BudgetService extends BaseYCService { constructor ( private logger: LogService, private budgetResources: BudgetResources, private notifier: NotifierService, private i18n: I18nService, private arrayHelper: ArrayHelpersService, private programService: ProgramService, private currencyService: CurrencyService, private clientSettingsService: ClientSettingsService, private spinnerService: SpinnerService, private maskingService: MaskingService, private fileService: FileService ) { super(); } get budgets () { return this.get('budgets'); } get budgetsForDashboard () { return this.get('budgetsForDashboard'); } get budgetNameMap () { return this.get('budgetNameMap'); } get fundingSourceNameMap () { return this.get('fundingSourceNameMap'); } get allFundingSources () { return this.openFundingSources?.concat(this.closedFundingSources); } get openFundingSources () { return this.get('openFundingSources'); } get closedFundingSources () { return this.get('closedFundingSources'); } get unallocatedSourceMap () { return this.get('unallocatedSourceMap'); } get allBudgetOptions () { return this.get('allBudgetOptions'); } get allSourceOptions () { return this.get('allSourceOptions'); } get myBudgetOptions () { return this.get('myBudgetOptions'); } get mySourceOptions () { return this.get('mySourceOptions'); } get cashBudgetOptions () { return this.get('cashBudgetOptions'); } get cashSourceOptions () { return this.get('cashSourceOptions'); } get inKindBudgetOptions () { return this.get('inKindBudgetOptions'); } get inKindSourceOptions () { return this.get('inKindSourceOptions'); } get noInKindSourcesExist () { return (this.openFundingSources || []).filter((source) => { return source.type === FundingSourceTypes.UNITS; }).length === 0; } get noCashSourcesExist () { return (this.openFundingSources || []).filter((source) => { return source.type === FundingSourceTypes.DOLLARS; }).length === 0; } getEditBudgetById (id: number) { return this.get('budgetEditMap')[id]; } attachEditBudget (budget: BudgetDetail) { const id = budget.id || 'new'; this.set('budgetEditMap', { ...this.get('budgetEditMap'), [id]: budget }); } setBudgetsOnState (budgets: Budget[]) { this.set('budgets', budgets); } setClosedBudgets (budgets: Budget[]) { this.set('closedBudgets', budgets); } setOpenBudgets (budgets: Budget[]) { this.set('openBudgets', budgets); } setClosedFundingSources (fundingSources: FundingSource[]) { this.set('closedFundingSources', fundingSources); } setOpenFundingSources (fundingSources: FundingSource[]) { this.set('openFundingSources', fundingSources); } setBudgetFundingSources (fundingSources: SimpleBudgetFundingSource[]) { this.set('budgetFundingSources', fundingSources); } setFundingSourceNameMap (map: { [f: string]: string; }) { this.set('fundingSourceNameMap', map); } setBudgetMap (map: { [b: string]: BudgetDetail; }) { this.set('budgetMap', map); } setBudgetDrilldownMap (id: number, drilldownInfo: BudgetDrilldownInfo) { const map = { ...this.get('budgetDrilldownMap'), [id]: drilldownInfo }; this.set('budgetDrilldownMap', map); } resetFundingSourceDrilldownMap () { this.set('fundingSourceDrilldownMap', {}); } setFundingSourceDrilldownMap (id: number, drilldownInfo: FundingSourceDrilldownInfo) { const map = { ...this.get('fundingSourceDrilldownMap'), [id]: drilldownInfo }; this.set('fundingSourceDrilldownMap', map); } getFundingSourceDetail (fundingSourceId: number) { return this.allFundingSources.find((fundingSource) => { return fundingSource.id === fundingSourceId; }); } isSameBudgetAndSource ( budgetIdOne: number, budgetIdTwo: number, fsIdOne: number, fsIdTwo: number ) { return (budgetIdOne === budgetIdTwo) && (fsIdOne === fsIdTwo); } async setBudgets () { if (!this.budgets) { const budgets = await this.budgetResources.getBudgets(); const budgetNameMap: { [x: number]: string; } = {}; budgets.forEach((budget: Budget) => { budgetNameMap[budget.id] = budget.name; }); this.set('budgetNameMap', budgetNameMap); this.setBudgetsOnState(budgets); const closedBudgets = budgets.filter((budget) => { return budget.isClosed; }); const openBudgets = budgets.filter((budget) => { return !budget.isClosed; }); this.setClosedBudgets(closedBudgets); this.setOpenBudgets(openBudgets); this.resetFundingSourceDrilldownMap(); this.setSimpleBudgetMap(); return budgets; } return this.budgets; } setSimpleBudgetMap () { this.budgets.forEach((budget) => { this.set('simpleBudgetMap', { ...this.get('simpleBudgetMap'), [budget.id]: budget }); }); } async getBudgetDrilldownInfo (id: number) { const drilldownInfo = await this.budgetResources.getBudgetDrilldownInfo(id); this.setBudgetDrilldownMap(id, drilldownInfo); return this.get('budgetDrilldownMap')[id]; } async getFundingSourceDrilldownInfo (id: number) { if (!this.get('fundingSourceDrilldownMap')[id]) { const drilldownInfo = await this.budgetResources.getFundingSourceDrilldownInfo(id); this.setFundingSourceDrilldownMap(id, drilldownInfo); return this.get('fundingSourceDrilldownMap')[id]; } else { return this.get('fundingSourceDrilldownMap')[id]; } } async getFundingSourcesForDashboard (force = false) { if (force || !this.get('fundingSourcesForDashboard')) { const fundingSources = await this.budgetResources.getFundingSourcesForDashboard(); const nonArchivedFundingSources = fundingSources.filter((fs) => { return !fs.isArchived; }); this.set('fundingSourcesForDashboard', nonArchivedFundingSources); return nonArchivedFundingSources; } return this.get('fundingSourcesForDashboard'); } async getBudgetsForDashboard () { if (!this.get('budgetsForDashboard')) { const budgets = await this.budgetResources.getBudgetsForDashboard(); this.set('budgetsForDashboard', budgets); } } resetBudgetsForDashboard () { this.set('budgetsForDashboard', undefined); } async setBudgetForDashboard (id: number) { const [ detail, stats, programs, fundingSources ] = await Promise.all([ this.getBudgetDetail(id), this.budgetResources.getBudgetStats([id]), this.budgetResources.getProgramsForBudgetDashboard(id), this.budgetResources.getFundingSourcesForBudgetDashboard(id) ]); programs.forEach((program) => { const map = this.programService.programTranslationMap[program.programId]; program.programName = map && map.Name ? map.Name : program.programName; }); const sources = fundingSources.filter((source) => { if (source.isOverage) { return source.totalSpent > 0; } return source; }); this.set('budgetDashboardMap', { ...this.get('budgetDashboardMap'), [id]: { detail, stats, programs: this.arrayHelper.sort( programs, 'paymentsAmount', true ), sources: this.arrayHelper.sort( sources, 'totalRemaining', true ) } }); } async setFundingSourceForDashboard (id: number) { const [ stats, budgets ] = await Promise.all([ this.budgetResources.getFundingSourceStats([id]), this.budgetResources.getBudgetsByFundingSourceID(id) ]); this.set('fundingSourceDashboardMap', { ...this.get('fundingSourceDashboardMap'), [id]: { stats, budgets } }); } async setBudgetOptions () { const [ segmented ] = await Promise.all([ this.budgetResources.getBudgetsSegmented(), this.setBudgets() ]); const myBudgetOptions: TypeaheadSelectOption[] = []; const cashOptions = this.budgets.filter((budget) => { return budget.fundingSourceType === FundingSourceTypes.DOLLARS; }).map((budget) => { const budgetOption = { label: budget.name, value: budget.id }; if (segmented.includes(budget.id)) { myBudgetOptions.push(budgetOption); } return budgetOption; }); const inKindOptions = this.budgets.filter((budget) => { return budget.fundingSourceType === FundingSourceTypes.UNITS; }).map((budget) => { const budgetOption = { label: budget.name, value: budget.id }; if (segmented.includes(budget.id)) { myBudgetOptions.push(budgetOption); } return budgetOption; }); this.set( 'allBudgetOptions', this.arrayHelper.sort([ ...cashOptions, ...inKindOptions ], 'label') ); this.set( 'cashBudgetOptions', this.arrayHelper.sort(cashOptions, 'label') ); this.set( 'inKindBudgetOptions', this.arrayHelper.sort(inKindOptions, 'label') ); this.set( 'myBudgetOptions', this.arrayHelper.sort(myBudgetOptions, 'label') ); } async setSourceOptions () { const [ segmented ] = await Promise.all([ this.budgetResources.getFundingSourcesSegmented(), this.setFundingSources() ]); const cashOptions = this.openFundingSources.filter((source) => { return source.type === FundingSourceTypes.DOLLARS; }).map((source) => { return { label: source.name, value: source.id }; }); const inKindOptions = this.openFundingSources.filter((source) => { return source.type === FundingSourceTypes.UNITS; }).map((source) => { return { label: source.name, value: source.id }; }); const mySourceOptions: TypeaheadSelectOption[] = []; this.allFundingSources.forEach((source) => { if (segmented.includes(source.id)) { const sourceOption = { label: source.name, value: source.id }; mySourceOptions.push(sourceOption); } }); this.set( 'allSourceOptions', this.arrayHelper.sort([ ...cashOptions, ...inKindOptions ], 'label') ); this.set( 'cashSourceOptions', this.arrayHelper.sort(cashOptions, 'label') ); this.set( 'inKindSourceOptions', this.arrayHelper.sort(inKindOptions, 'label') ); this.set( 'mySourceOptions', this.arrayHelper.sort(mySourceOptions, 'label') ); } getBudgetDetail (budgetId: number) { return this.budgetResources.getBudget(budgetId); } async getBudget (budgetId: number|'new') { let budget: BudgetDetail; if (budgetId === 'new') { budget = { name: '', description: '', budgetFundingSources: [] }; } else { budget = await this.getBudgetDetail(budgetId); } this.attachEditBudget(budget); return budget; } adaptUiBudgetToApiBudget (data: BudgetDetail): BudgetSave { return { id: data.id, description: data.description, name: data.name, fundingSources: data.budgetFundingSources.map(source => ({ fundingSourceId: source.fundingSourceId, fundingSourceType: source.fundingSourceType, totalAmount: source.totalAmount, totalUnits: source.totalUnits })), accountNumber: data.accountNumber }; } async resetBudgets () { this.setBudgetsOnState(undefined); await this.setBudgets(); } async saveBudget (data: BudgetDetail) { try { const adapted = this.adaptUiBudgetToApiBudget(data); await this.budgetResources.saveBudget(adapted); await this.resetBudgets(); this.resetBudgetsForDashboard(); this.notifier.success(this.i18n.translate( 'BUDGET:textSuccessfullySavedBudget', {}, 'Successfully saved the budget' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'BUDGET:textErrorSavingBudget', {}, 'There was an error saving the budget' )); } } async resetBudgetDetail (programBudgets: number[]) { programBudgets.forEach((budget) => { this.set('budgetMap', { ...this.get('budgetMap'), [budget]: null }); }); } async setBudgetMapDetail (budgetId: number) { const budgetMap = this.get('budgetMap'); if (!budgetMap[budgetId]) { const detail = await this.getBudgetDetail(budgetId); detail.isClosed = this.getIsClosed(budgetId); detail.budgetFundingSources.forEach((source) => { source.isClosed = this.getIsClosed(source.fundingSourceId, false); }); this.setBudgetMap({ ...budgetMap, [budgetId]: detail }); } } getIsClosed (id: number, isBudget = true) { if (isBudget) { const found = this.budgets.find((budget) => budget.id === id); return found.isClosed; } else { const foundSource = this.allFundingSources.find((s) => { return s.id === id; }); return foundSource.isClosed; } } async getUnitCostMap () { const unitCostMap: { [x: string]: number; } = {}; await this.setFundingSources(); this.allFundingSources.forEach((fs) => { unitCostMap[fs.id] = fs.unitCost; }); return unitCostMap; } async saveFundingSource ( data: FundingSource ) { try { await this.budgetResources.saveFundingSource(data); this.notifier.success(this.i18n.translate( data.id ? 'BUDGET:textSuccessfullyUpdateFundingSource' : 'BUDGET:textSuccessfullyAddFundingSource', {}, `Successfully ${data.id ? 'updated' : 'added'} the funding source` )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( data.id ? 'BUDGET:textErrorUpdatingTheFundingSource' : 'BUDGET:textErrorAddingTheFundingSource', {}, `There was an error ${data.id ? 'updating' : 'adding'} the funding source` )); } } async setFundingSources () { if (!this.openFundingSources || !this.closedFundingSources) { const fundingSources = await this.budgetResources.getFundingSources(); const openFS = fundingSources.filter((fs) => { return !fs.isClosed; }); const closedFS = fundingSources.filter((fs) => { return fs.isClosed; }); this.setOpenFundingSources(openFS); this.setClosedFundingSources(closedFS); this.setupFundingSourceNameMap(); this.setFundingSourceUnallocatedMap(); } } setupFundingSourceNameMap () { const fundingNameMap: { [x: string]: string; } = {}; this.allFundingSources.forEach((source: FundingSource) => { fundingNameMap[source.id] = source.name; }); this.setFundingSourceNameMap(fundingNameMap); } async resetFundingSources () { this.setOpenFundingSources(undefined); this.setClosedFundingSources(undefined); this.set('unallocatedSourceMap', undefined); await this.setFundingSources(); } setFundingSourceUnallocatedMap () { const map: SimpleStringMap = {}; this.allFundingSources.forEach((source) => { map[source.id] = source.amountUnallocated; }); this.set('unallocatedSourceMap', map); } // ** For Simple Award Modal * // async getBudgetsFilteredByProgramAndProcessor ( programId: number, isEligibleForGiving: boolean, cycleId: number, existingBudgetId?: number ) { const cycle = await this.programService.getCycleFromProgram(programId, cycleId); let allowCash = false; let allowUnits = false; // Filter for Program const filteredBudgets = this.budgets.filter((budget) => { if (existingBudgetId === budget.id) { return true; } return cycle.budgetIds.includes(budget.id); }) .map((budget) => { if (budget.fundingSourceType === FundingSourceTypes.DOLLARS) { allowCash = true; } else if (budget.fundingSourceType === FundingSourceTypes.UNITS) { allowUnits = true; } return budget; }); const budgetDetails = await Promise.all(filteredBudgets.map((budget) => { return this.getBudgetDetail(budget.id); })); budgetDetails.forEach((detail) => { const budgetMap = this.get('budgetMap'); detail.isClosed = this.getIsClosed(detail.id); detail.budgetFundingSources.forEach((source) => { source.isClosed = this.getIsClosed(source.fundingSourceId, false); }); this.setBudgetMap({ ...budgetMap, [detail.id]: detail }); }); if (!isEligibleForGiving) { const cashPassedVetting = budgetDetails.some((budget) => { const type = budget.budgetFundingSources[0].fundingSourceType; return type === FundingSourceTypes.DOLLARS && budget.hasClientProcessingType; }); const inKindPassedVetting = budgetDetails.some((budget) => { const type = budget.budgetFundingSources[0].fundingSourceType; return type === FundingSourceTypes.UNITS && budget.hasClientProcessingType; }); allowCash = allowCash && cashPassedVetting; allowUnits = allowUnits && inKindPassedVetting; } return { allowCash, allowUnits, filteredBudgets }; } async getDefaultBudgetForNewPayment ( options: TypeaheadSelectOption[], isUnits: boolean, programId: number, cycleId: number, assignedBudgetId?: number, assignedFsId?: number ) { const cycle = await this.programService.getCycleFromProgram(programId, cycleId); let budgetId = !isUnits ? cycle.defaultCashBudgetId : cycle.defaultInKindBudgetId; let fsId = !isUnits ? cycle.defaultCashFundingSourceId : cycle.defaultInKindFundingSourceId; if (!isUnits && assignedBudgetId && assignedFsId) { budgetId = assignedBudgetId; fsId = assignedFsId; } const found = options.find(option => { const combo = option.value; return (combo.budget.id === budgetId) && combo.fundingSource.fundingSourceId === fsId; }); if (found) { return found.value; } let budgetWithFunds: BudgetFundingSourceCombo; options.forEach((option) => { const combo = option.value; if (!budgetWithFunds && !!combo.fundingSource.amountRemaining) { budgetWithFunds = combo; } }); return budgetWithFunds || options[0].value; } getBudgetFundingSourceComboOptions ( budgetList: Budget[], orgEligibleStatusArray: OrganizationEligibleForGivingStatus[], existingBudgetId?: number, existingFsId?: number ) { const closedText = this.i18n.translate('GLOBAL:textClosed').toLowerCase(); const mappedList = budgetList.map(budget => { return this.get('budgetMap')[budget.id]; }).reduce((acc, budget) => { return [ ...acc, ...budget.budgetFundingSources .map((source) => { const processorText = source.processingTypeId === ProcessingTypes.Client ? this.i18n.translate( 'GLOBAL:textClientProcessor', {}, 'Client processor' ) : this.i18n.translate( 'GLOBAL:textYourCauseProcessor', {}, 'YourCause processor' ); return { label: `${budget.name} ${ budget.isClosed ? `(${closedText}) ` : '' }- ${source.fundingSourceName} ${ source.isClosed ? `(${closedText}) ` : '' }`, htmlLabel: ` ${budget.name} ${budget.isClosed ? `(${closedText}) ` : ''} - ${source.fundingSourceName} ${source.isClosed ? `(${closedText}) ` : ''} (${processorText}) `, value: { budget, fundingSource: source, isClosed: budget.isClosed || source.isClosed, comboId: budget.id + '-' + source.fundingSourceId } }; }) ]; }, []); return this.filterForVetting( mappedList, orgEligibleStatusArray, existingBudgetId, existingFsId ); } filterForVetting ( options: TypeaheadSelectOption[], orgEligibleStatusArray: OrganizationEligibleForGivingStatus[], existingBudgetId?: number, existingFsId?: number ) { let onlyShowClientProcessed = false; const allOrgsCanYcProcess = orgEligibleStatusArray.every((status) => { return status === OrganizationEligibleForGivingStatus.ELIGIBLE; }); if (!allOrgsCanYcProcess) { onlyShowClientProcessed = true; } if (onlyShowClientProcessed) { return options.filter((budgetFs) => { if ( existingBudgetId === budgetFs.value.budget.id && existingFsId === budgetFs.value.fundingSource.fundingSourceId ) { return true; } return budgetFs.value.fundingSource.processingTypeId === ProcessingTypes.Client; }); } return options; } getRemainingAmountBudgetMap ( options: TypeaheadSelectOption[] = [], currentBudgetFs: BudgetFundingSourceCombo, currentPaymentAmount: number, originalPaymentAmount: number|string, isSimpleAward: boolean, isUnits: boolean, allowOverage: boolean, appReservedInfo: ApplicationBudgetInfo[], originalBudgetId: number, originalFsId: number ) { let map: RemainingAmountBudgetMap = {}; const hasOverage = this.clientSettingsService.clientSettings.allowBudgetOverages; options.forEach((option) => { const budgetFundingSource = option.value; if (budgetFundingSource) { const sourceToMap = budgetFundingSource.fundingSource; const currentBudgetId = currentBudgetFs.budget.id; const currentSourceId = currentBudgetFs.fundingSource.fundingSourceId; const remaining = this.getRemainingAmountForBudgetFs( budgetFundingSource, currentBudgetId, currentSourceId, currentPaymentAmount, originalPaymentAmount, appReservedInfo, originalBudgetId, originalFsId ); const formattedRemainingAmount = this.currencyService.formatMoney( remaining < 0 ? 0 : remaining ); const sameBudgetAndSource = this.isSameBudgetAndSource( currentBudgetId, sourceToMap.budgetId, currentSourceId, sourceToMap.fundingSourceId ); let helpText = ''; if (sameBudgetAndSource) { helpText = this.i18n.translate( remaining < 0 ? 'AWARDS:textZeroAvailableForPayment' : 'AWARDS:textAmountAvailableAfterThisPayment', { amount: formattedRemainingAmount }, remaining < 0 ? '__amount__ available for payment' : '__amount__ available after this payment' ); } else { helpText = this.i18n.translate( 'GLOBAL:textAvailableDynamic', { amount: formattedRemainingAmount }, '__amount__ available' ); } let additionalHelpText = ''; let canMoveFunds = false; const isNegative = remaining < 0; if (sameBudgetAndSource && isNegative) { if (allowOverage && hasOverage && !budgetFundingSource.isClosed) { const availToMove = this.unallocatedSourceMap[currentSourceId]; const canCoverAll = (remaining + availToMove) >= 0; const isEditPayment = !!originalPaymentAmount; if ( availToMove <= 0 || (!canCoverAll && isUnits) ) { additionalHelpText = !isEditPayment ? this.i18n.translate( 'AWARDS:textSelectAnotherBudgetFsToProceed', {}, 'Select another budget / funding source to proceed.' ) : ''; } else { canMoveFunds = true; if (isEditPayment) { additionalHelpText = this.i18n.translate( isSimpleAward ? 'AWARDS:textClickSaveMoveFunds' : 'AWARDS:textClickApproveAwardPayMoveFunds', {}, isSimpleAward ? `Click 'Save' to move funds.` : `Click 'Approve, award, and pay' to move funds.` ); } else { additionalHelpText = this.i18n.translate( isSimpleAward ? 'AWARDS:textChooseAnotherBudgetFsOrClickSaveMoveFunds' : 'AWARDS:textChooseAnotherBudgetFsOrClickApproveAwardPayMoveFunds', {}, isSimpleAward ? `Choose another budget / funding source or click 'Save' to move funds.` : `Choose another budget / funding source or click 'Approve, award, and pay' to move funds.` ); } } } } const comboId = option.value.comboId; map = { ...map, [comboId]: { label: helpText, value: remaining, helpDisplay: `${helpText}${ additionalHelpText ? `. ${additionalHelpText}` : '' }`, canMoveFunds } }; } }); return map; } getRemainingAmountForBudgetFs ( budgetFundingSource: BudgetFundingSourceCombo, currentBudgetId: number, currentSourceId: number, currentPaymentAmount: number, originalPaymentAmount: number|string, appReservedInfo: ApplicationBudgetInfo[], originalBudgetId: number, originalFsId: number ) { const skipClosedLogic = this.getSkipClosedLogic( originalPaymentAmount, budgetFundingSource.budget.id, budgetFundingSource.fundingSource.fundingSourceId, originalBudgetId, originalFsId ); const isClosed = !skipClosedLogic && budgetFundingSource.isClosed; const sourceToMap = budgetFundingSource.fundingSource; const totalAmount = !isClosed ? sourceToMap.totalAmount : 0; const totalAmountPayments = !isClosed ? sourceToMap.totalAmountPayments : 0; let reservedAmount = 0; if ( !isClosed && sourceToMap.reservedAmount && this.clientSettingsService.clientSettings.reserveFunds ) { // Should not subtract reservations // if the funds were reserved for THIS app const reservations = (appReservedInfo || []).filter((app) => { return this.isSameBudgetAndSource( budgetFundingSource.budget.id, app.budgetId, budgetFundingSource.fundingSource.fundingSourceId, app.fundingSourceId ); }).reduce((acc, app) => { return acc + app.amountReserved; }, 0); reservedAmount = sourceToMap.reservedAmount - reservations; } let pendingAmount = 0; const isCurrentBudgetFs = this.isSameBudgetAndSource( budgetFundingSource.budget.id, currentBudgetId, budgetFundingSource.fundingSource.fundingSourceId, currentSourceId ); const budgetFsChanged = budgetFundingSource?.budget.id !== originalBudgetId || budgetFundingSource.fundingSource?.fundingSourceId !== originalFsId; if (isCurrentBudgetFs) { if (isClosed) { return totalAmount - currentPaymentAmount; } if (!!originalPaymentAmount) { if (!budgetFsChanged) { pendingAmount = +currentPaymentAmount - +originalPaymentAmount; } else { pendingAmount = currentPaymentAmount; } } else { pendingAmount = +currentPaymentAmount; } } return totalAmount - totalAmountPayments - pendingAmount - reservedAmount; } getSkipClosedLogic ( originalPaymentAmount: number|string, selectedBudgetId: number, selectedFsId: number, originalBudgetId: number, originalFsId: number ) { return !!originalPaymentAmount && selectedBudgetId === originalBudgetId && selectedFsId === originalFsId; } // ** End For Simple Award Modal * // async closeBudget (payload: OpenCloseBudgetAPI.CloseBudgetPayload) { this.spinnerService.startSpinner(); try { await this.budgetResources.closeBudget(payload); await this.resetBudgets(); this.notifier.success( this.i18n.translate( 'BUDGET:textSuccessfullyCloseBudget', {}, 'Successfully closed budget' ) ); this.spinnerService.stopSpinner(); } catch (e) { this.logger.error(e); this.spinnerService.stopSpinner(); this.notifier.error( this.i18n.translate( 'BUDGET:textErrorClosingBudget', {}, 'There was an error closing budget' ) ); } } async openBudget (budgetId: number) { this.spinnerService.startSpinner(); try { await this.budgetResources.openBudget(budgetId); await this.resetBudgets(); this.notifier.success( this.i18n.translate( 'BUDGET:textSuccessfullyOpenedBudget', {}, 'Successfully opened budget' ) ); this.spinnerService.stopSpinner(); } catch (e) { this.logger.error(e); this.spinnerService.stopSpinner(); this.notifier.error( this.i18n.translate( 'BUDGET:textErrorOpeningBudget', {}, 'There was an error opening this budget' ) ); } } async closeFundingSource (payload: number) { this.spinnerService.startSpinner(); try { await this.budgetResources.closeFundingSource([payload]); await this.resetFundingSources(); this.notifier.success( this.i18n.translate( 'BUDGET:textSuccessfullyCloseFundingSource', {}, 'Successfully closed funding source' ) ); this.spinnerService.stopSpinner(); } catch (e) { this.logger.error(e); this.spinnerService.stopSpinner(); this.notifier.error( this.i18n.translate( 'BUDGET:textErrorClosingFundingSource', {}, 'There was an error closing funding source' ) ); } } async openFundingSource (payload: number) { this.spinnerService.startSpinner(); try { await this.budgetResources.openFundingsource(payload); await this.resetFundingSources(); this.notifier.success( this.i18n.translate( 'BUDGET:textSuccessfullyOpenedFundingSource', {}, 'Successfully opened funding source' ) ); this.spinnerService.stopSpinner(); } catch (e) { this.logger.error(e); this.spinnerService.stopSpinner(); this.notifier.error( this.i18n.translate( 'BUDGET:textErrorOpeningFundingSource', {}, 'There was an error opening this funding source' ) ); } } async deleteFundingSource (fundingSourceId: number) { await this.budgetResources.deleteFundingSource(fundingSourceId); await this.resetFundingSources(); } handleBudgetFundingSourceModalResponse ( response: BudgetFundingSourceModalResponse, budget: BudgetDetail, index?: number ): BudgetFundingSource[] { const formattingData = this.currencyService.formattingData; const totalAmount = this.maskingService.unmaskNumber( formattingData[this.clientSettingsService.defaultCurrency], response.amount || '0' ); if (index || index === 0) { const existingSource = budget.budgetFundingSources[index]; return [ ...budget.budgetFundingSources.slice(0, index), { ...existingSource, totalAmount, amountRemaining: +totalAmount - existingSource.totalAmountPayments }, ...budget.budgetFundingSources.slice(index + 1) ]; } else { const found = this.allFundingSources.find((source) => { return source.id === response.source.fundingSourceId; }); return [ ...budget.budgetFundingSources, { budgetId: budget.id, fundingSourceId: response.source.fundingSourceId, fundingSourceType: response.source.fundingSourceType, totalAmount, active: true, fundingSourceName: response.source.fundingSourceName, totalAmountPayments: response.source.totalAmountPayments, processingTypeId: found.processingTypeId, amountUnavailable: 0, amountRemaining: +totalAmount, reservedAmount: response.source.reservedAmount, isClosed: found.isClosed } ]; } } getActionMessagesForAuditTrail ( items: BudgetFundingSourceAudit[], isFundingSource = false, currentBudgetId?: number ) { return items.map((item) => { this.getImpactDisplayAndColor(item); switch (item.actionType) { case AuditTypes.FundingSourceCreated: item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceCreated', {}, 'Funding source created' ); break; case AuditTypes.BudgetCreated: item.impactDisplay = ''; item.actionMessage = this.i18n.translate( 'BUDGET:textBudgetCreated', {}, 'Budget created' ); break; case AuditTypes.FundingSourceAdded: item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceAddedDynamic', { fundingSourceName: item.fundingSourceName }, '__fundingSourceName__ added' ); break; case AuditTypes.FundingSourceAmountUpdated: if (!!item.impactAmount) { item.actionMessage = this.i18n.translate( item.impactAmount > 0 ? 'BUDGET:textFundingSourceIncreased' : 'BUDGET:textFundingSourceDecreased', {}, item.impactAmount > 0 ? 'Funding source increased' : 'Funding source decreased' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceAmountUpdated', {}, 'Funding source amount updated' ); } break; case AuditTypes.BudgetAllocationUpdated: if (item.impactAmount > 0) { item.actionMessage = this.i18n.translate( isFundingSource ? 'BUDGET:textAllocationToBudgetDynamic' : 'BUDGET:textFundingSourceAllocationIncreasedDynamic', { budgetName: item.infoBudgetName, fundingSourceName: item.fundingSourceName }, isFundingSource ? 'Allocation to __budgetName__' : '__fundingSourceName__ allocation increased' ); } else if (item.impactAmount < 0) { item.actionMessage = this.i18n.translate( isFundingSource ? 'BUDGET:textAllocationToBudgetDynamic' : 'BUDGET:textFundingSourceAllocationDecreasedDynamic', { budgetName: item.infoBudgetName, fundingSourceName: item.fundingSourceName }, isFundingSource ? 'Allocation to __budgetName__' : '__fundingSourceName__ allocation decreased' ); } break; case AuditTypes.OverageUsed: if (isFundingSource) { item.actionMessage = this.i18n.translate( 'BUDGET:textOverageAllocationForBudgetDynamic', { budgetName: item.infoBudgetName }, 'Overage allocation for __budgetName__' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceAllocationIncreasedForOverage', { fundingSourceName: item.fundingSourceName }, '__fundingSourceName__ allocation increased for overage' ); } break; case AuditTypes.FundingSourceClosed: item.impactColor = ''; if (isFundingSource) { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceClosed', {}, 'Funding source closed' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceClosedDynamic', { fundingSourceName: item.fundingSourceName }, '__fundingSourceName__ closed' ); } break; case AuditTypes.FundingSourceOpened: item.impactColor = ''; if (isFundingSource) { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceOpened', {}, 'Funding source opened' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceOpenedDynamic', { fundingSourceName: item.fundingSourceName }, '__fundingSourceName__ opened' ); } break; case AuditTypes.BudgetClosed: item.impactColor = ''; if (item.impactAmount === 0) { item.impactDisplay = ''; } item.actionMessage = this.i18n.translate( 'BUDGET:textBudgetClosed', {}, 'Budget closed' ); break; case AuditTypes.BudgetOpened: item.impactColor = ''; item.actionMessage = this.i18n.translate( 'BUDGET:textBudgetOpened', {}, 'Budget opened' ); break; case AuditTypes.FundsReallocated: item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceFundsReallocatedDynamic', { fundingSourceName: item.fundingSourceName, budgetName: item.infoBudgetName }, '__fundingSourceName__ funds reallocated to __budgetName__' ); break; case AuditTypes.FundsReturnedToSource: if (isFundingSource) { item.actionMessage = this.i18n.translate( 'BUDGET:textFundsReturnedFromClosedBudgetDynamic', { budgetName: item.infoBudgetName }, 'Funds returned from closed budget __budgetName__' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceFundsReturnedDynamic', { fundingSourceName: item.fundingSourceName }, '__fundingSourceName__ funds returned to source' ); } break; case AuditTypes.BudgetDeleted: item.impactColor = ''; if (isFundingSource) { item.actionMessage = this.i18n.translate( 'BUDGET:textFundsReturnedFromDeletedBudgetDynamic', { budgetName: item.infoBudgetName }, 'Funds returned from deleted budget __budgetName__' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textBudgetDeleted', {}, 'Budget deleted' ); } break; case AuditTypes.FundingSourceDeleted: item.impactColor = ''; if (isFundingSource) { item.actionMessage = this.i18n.translate( 'BUDGET:textSourceDeletedFromBudgetDynamic2', { budgetName: item.infoBudgetName }, 'Funding source deleted from __budgetName__' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceDeletedFromBudgetDynamic', { fundingSourceName: item.fundingSourceName }, '__fundingSourceName__ deleted from budget' ); } break; case AuditTypes.BudgetAllocationUpdatedFromClosedBudget: if (isFundingSource) { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceAllocationIncreasedViaClosingBudget', { budgetName: item.infoBudgetName }, 'Allocation increased via closing __budgetName__' ); } else if (+item.infoBudgetId === +currentBudgetId) { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceAllocationIncreasedViaClosingBudget2', { fundingSourceName: item.fundingSourceName }, '__fundingSourceName__ allocation increased via closing budget' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textFundingSourceAllocationIncreasedViaClosingBudget3', { fundingSourceName: item.fundingSourceName, budgetName: item.infoBudgetName }, '__fundingSourceName__ allocation increased via closing __budgetName__' ); } break; case AuditTypes.FundingSourceReallocationToBudgetViaClosingBudget: if (isFundingSource) { item.impactColor = ''; // no '+' for this one per David item.impactDisplay = this.currencyService.formatMoney(item.impactAmount); item.actionMessage = this.i18n.translate( 'BUDGET:textReallocationToNewBudgetViaClosing', { newBudgetName: item.budgetName, closedBudgetName: item.infoBudgetName }, 'Reallocation to __newBudgetName__ via closing __closedBudgetName__' ); } else { item.actionMessage = this.i18n.translate( 'BUDGET:textReallocationToBudgetViaClosing', { budgetName: item.infoBudgetName }, 'Reallocation to budget via closing __budgetName__' ); } break; case AuditTypes.BudgetAccountNumberChanged: item.impactDisplay = ''; item.actionMessage = item.infoBudgetAccountNumber ? this.i18n.translate( 'BUDGET:textAccountNumberUpdatedDynamic', { accountNumber: item.infoBudgetAccountNumber }, 'Account number updated to __accountNumber__' ) : this.i18n.translate( 'BUDGET:textAccountNumberRemoved', {}, 'Account number removed' ); break; case AuditTypes.FundingSourceNoReallocationToBudgetViaClosingBudget: item.impactColor = ''; item.actionMessage = this.i18n.translate( 'BUDGET:textBudgetClosedFundsUnavailable', { budgetName: item.infoBudgetName || item.budgetName }, '__budgetName__ closed. Funds unavailable.' ); break; } return item; }); } getImpactDisplayAndColor (item: BudgetFundingSourceAudit) { if (item.impactAmount > 0) { item.impactColor = 'text-success'; item.impactDisplay = `+${this.currencyService.formatMoney(item.impactAmount)}`; } else if (item.impactAmount < 0) { item.impactColor = 'text-danger'; item.impactDisplay = `-${this.currencyService.formatMoney( Math.abs(item.impactAmount) )}`; } else { item.impactDisplay = this.currencyService.formatMoney(item.impactAmount); } } getUrlForDetail ( isBudget: boolean, id: number ) { if (isBudget) { return `/management/program-setup/budgets/${id}/funding-sources`; } else { return `/management/program-setup/funding-sources/${id}/audit-trail`; } } getBudgetImportTemplate ( fsIds: number[] ) { const fundingSources = this.allFundingSources.filter((fs) => fsIds.some((id) => id === fs.id)); const headerArray = ['Budget Name', 'Budget Description']; fundingSources.forEach((fs) => { headerArray.push(fs.name); }); const headerString = headerArray.join(','); return this.fileService.downloadString( headerString, 'text/csv', 'template.csv' ); } getBudgetImportModel ( fsIds: number[] ) { class ExtendedBudgetImportModel extends BudgetImportModel { } const fundingSources = this.allFundingSources.filter((fs) => fsIds.some((id) => id === fs.id)); const validator = this.generateFundingSourceBudgetImportValidator(fundingSources); validator()(ExtendedBudgetImportModel); fundingSources.forEach((source) => { IsNumber({ min: 0 })(ExtendedBudgetImportModel.prototype, source.name); Transform((val: string) => Number(val))(ExtendedBudgetImportModel.prototype, source.name); }); return ExtendedBudgetImportModel; } /** * * @param budgetData dynamically generated import data, headers will be funding sources * @returns adaped payload for api */ adaptBudgetImportDataForAPI (budgetData: BudgetImportModel[]): BudgetImport { const fundingSources = this.openFundingSources; const budgets = budgetData.reduce((acc, curr, _index) => { const allocations: FundingSourceAllocationsImport[] = []; Object.keys(curr).forEach((key) => { const obj = curr as any; const fundingSource = fundingSources.find((fs) => fs.name === key); const allocatedAmount = +obj[key]; if (fundingSource && allocatedAmount > 0) { allocations.push({ fundingSourceId: fundingSource.id, fundingSourceType: fundingSource.type, allocatedAmount }); } }); const budget: BudgetForImport = { budgetName: budgetData[_index]['Budget Name'], budgetDescription: budgetData[_index]['Budget Description'], fundingSourceAllocations: allocations }; return [ ...acc, budget ]; }, []); return { budgets }; } /** * * @param payload list of budgets with allocation for bulk add */ async handleBudgetImport (payload: BudgetImport) { try { this.spinnerService.startSpinner(); await this.budgetResources.importBudgets(payload); await Promise.all([ this.resetBudgets(), this.resetFundingSources() ]); this.spinnerService.stopSpinner(); this.notifier.success( this.i18n.translate( 'BUDGET:textSuccessfullyImportedBudgets', {}, 'Successfully imported budgets' ) ); } catch (e) { this.spinnerService.stopSpinner(); this.logger.error(e); this.notifier.error( this.i18n.translate( 'BUDGET:textErrorImportingBudgets', {}, 'There was an error importing budgets' ) ); } } /** * * @param fundingSources these will be used to check the validate the import file's source columns * @returns potential errors related to funding source allocation */ private generateFundingSourceBudgetImportValidator (fundingSources: FundingSource[]) { return createTopLevelValidator((_arg) => (importRecords: BudgetImportModel[]): ValidatorReturn => { return this.validateFundingSourceAllocationsForBulkBudgetImport(fundingSources, importRecords); }); } validateFundingSourceAllocationsForBulkBudgetImport ( fundingSources: FundingSource[], importRecords: BudgetImportModel[] ) { const defaultCurrency = this.clientSettingsService.defaultCurrency; const errors = fundingSources.reduce((acc, fs) => { const allocationAggregate = importRecords.reduce((aggregate, rec) => { const record: any = rec; return +record[fs.name] + aggregate; }, 0); const hasAllocationError = allocationAggregate > fs.amountUnallocated;; if (hasAllocationError) { const amountUnallocated = this.currencyService.formatMoney( fs.amountUnallocated, defaultCurrency ); const amountAllocatedInImport = this.currencyService.formatMoney( allocationAggregate, defaultCurrency ); const error: TopLevelValidatorReturn = { prop: fs.name, i18nKey: 'BUDGET:textBudgetImportFundingSourceError2', defaultValue: 'Allocation exceeds available funds for __fsName__. Allocated in this import: __amountAllocatedInImport__. Total Available: __amountUnallocated__', context: { fsName: fs.name, amountUnallocated, amountAllocatedInImport } }; return [ ...acc, error ]; } return acc; }, [] as TopLevelValidatorReturn[]); return errors; } }