import { Injectable } from '@angular/core'; import { CurrencyService } from '@core/services/currency.service'; import { SpinnerService } from '@core/services/spinner.service'; import { StatusService } from '@core/services/status.service'; import { TimeZoneService } from '@core/services/time-zone.service'; import { TranslationService } from '@core/services/translation.service'; import { ApplicationForUi, ApplicationFromPaginated } from '@core/typings/application.typing'; import { Budget, BudgetFundingSourceCombo, FundingSourceTypes } from '@core/typings/budget.typing'; import { Payment, PaymentForApi, PaymentType, ProcessingTypes } from '@core/typings/payment.typing'; import { PaymentStatus } from '@core/typings/status.typing'; import { CyclesUI } from '@core/typings/ui/cycles.typing'; import { WorkflowLevel } from '@core/typings/workflow.typing'; import { AwardResources } from '@features/awards/award.resources'; import { AwardState } from '@features/awards/award.state'; import { BudgetService } from '@features/budgets/budget.service'; import { ClientSettingsService } from '@features/client-settings/client-settings.service'; import { InKindItemToAwardOrPay } from '@features/in-kind/in-kind.typing'; import { ProgramService } from '@features/programs/program.service'; import { EmailService } from '@features/system-emails/email.service'; import { SystemTagsService } from '@features/system-tags/system-tags.service'; import { SystemTags } from '@features/system-tags/typings/system-tags.typing'; import { WorkflowService } from '@features/workflow/workflow.service'; import { APIResult, ArrayHelpersService, FileService, OrganizationEligibleForGivingStatus, PaginationOptions, TextFriendlySpecialCharCleaner, TypeaheadSelectOption } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { ExchangeRate, MaskingService } from '@yourcause/common/masking'; import { NotifierService } from '@yourcause/common/notifier'; import { AttachYCState, BaseYCService } from '@yourcause/common/state'; import { uniq } from 'lodash'; import { ApproveAwardPayModalPayload, ApproveAwardPayPayload, Award, AwardForApi, AwardForDash, AwardFromApi, AwardModalPayload, MyAward } from './typings/award.typing'; @AttachYCState(AwardState) @Injectable({ providedIn: 'root' }) export class AwardService extends BaseYCService { awardTypeOptions = [{ label: this.i18n.translate('GLOBAL:textCash'), value: FundingSourceTypes.DOLLARS }, { label: this.i18n.translate('GLOBAL:textInKind', {}, 'In kind'), value: FundingSourceTypes.UNITS }]; constructor ( private logger: LogService, private awardResources: AwardResources, private arrayHelper: ArrayHelpersService, private i18n: I18nService, private systemTagsService: SystemTagsService, private notifier: NotifierService, private currencyService: CurrencyService, private clientSettingsService: ClientSettingsService, private programService: ProgramService, private maskingService: MaskingService, private statusService: StatusService, private budgetService: BudgetService, private workflowService: WorkflowService, private translationService: TranslationService, private timeZoneService: TimeZoneService, private emailService: EmailService, private spinnerService: SpinnerService, private fileService: FileService ) { super(); } get paymentStatusMap () { return this.statusService.paymentStatusMap; } get precisionMap () { return this.currencyService.get('precisionMap'); } get myAwards () { return this.get('myAwards'); } getAwards (applicationId: number) { return this.awardResources.getAwards(applicationId); } async getMyAwards () { let rows: MyAward[]; if (!this.get('myAwards')) { const data = await this.awardResources.getAwardsForApplicant(1, 1000); rows = data.data.map((award) => { award.organizationName = award.organizationName || this.i18n.translate('common:textNone'); return award; }); this.set('myAwards', rows); } } /** * Adapts an array of awards to the Award model * * @param awards: Awards to adapt * @returns the adapted awards */ constructAwards (awards: (MyAward|AwardFromApi)[]): Award[] { return awards.map((award) => { return { id: 'id' in award ? award.id : (award as MyAward).awardId, awardType: award.awardType, amount: award.amount, description: award.description, notifyApplicant: 'sendEmail' in award ? award.sendEmail : null, awardDate: 'awardDate' in award ? award.awardDate : award.createdDate, currencyRequested: award.currencyRequested || this.clientSettingsService.defaultCurrency, currencyRequestedAmountEquivalent: award.currencyRequestedAmountEquivalent, exchangeRate: 'exchangeRate' in award ? award.exchangeRate : null, exchangeRateDateTime: 'exchangeRateDateTime' in award ? award.exchangeRateDateTime : null, exchangeRateId: 'exchangeRateId' in award ? award.exchangeRateId : null, inKindItems: award.inKindItems, payments: award.payments.map((payment) => { const isUnits = payment.fundingSourceType === FundingSourceTypes.UNITS; const foundSource = this.budgetService.allFundingSources?.find((source) => { const paymentSourceId = 'fundingSourceId' in payment ? payment.fundingSourceId : null; return source.id === paymentSourceId; }); return { statusDate: payment.statusDate, id: payment.paymentId, paymentType: payment.paymentType, scheduledDate: payment.scheduledDate, amount: payment.totalAmount, type: this.i18n.translate( isUnits ? 'GLOBAL:textInKind' : 'GLOBAL:textCash', {}, !isUnits ? 'Cash' : 'In-Kind' ), typeId: payment.fundingSourceType, budget: 'budgetId' in payment ? payment.budgetId : null, budgetName: payment.budgetName, fundingSource: 'fundingSourceId' in payment ? payment.fundingSourceId : null, fundingSourceName: payment.fundingSourceName, isUnits, status: payment.status ? this.paymentStatusMap[payment.status].translated : '', hidePaymentStatus: 'alternatePaymentStatus' in payment ? payment.alternatePaymentStatus : false, alternatePaymentStatusText: 'alternatePaymentStatusText' in payment ? payment.alternatePaymentStatusText : '', statusId: payment.status, paymentDesignation: TextFriendlySpecialCharCleaner( 'paymentDesignation' in payment ? payment.paymentDesignation : '' ), notes: 'notes' in payment ? payment.notes : '', tags: 'paymentTags' in payment ? payment.paymentTags .filter((tag) => tag.active) .map((tag) => tag.tagId) : [], paymentNumber: payment.paymentNumber, isACH: payment.isACH, currencyRequested: payment.currencyRequested || award.currencyRequested || this.clientSettingsService.defaultCurrency, currencyRequestedAmountEquivalent: payment.currencyRequestedAmountEquivalent, differentThanConversion: 'differentThanConversion' in payment ? payment.differentThanConversion : null, exchangeRate: 'exchangeRate' in payment ? payment.exchangeRate : null, exchangeRateDateTime: 'exchangeRateDateTime' in payment ? payment.exchangeRateDateTime : null, exchangeRateId: 'exchangeRateId' in payment ? payment.exchangeRateId : null, issuedDate: 'issuedDate' in payment ? payment.issuedDate : null, clearedDate: 'clearedDate' in payment ? payment.clearedDate : null, createdDate: 'createdDate' in payment ? payment.createdDate : null, inKindItems: payment.inKindItems, substatus: payment.substatus, reissuedForPaymentId: payment.reissuedForPaymentId, reissuedInPaymentId: payment.reissuedInPaymentId, processor: foundSource?.processingTypeId ?? ProcessingTypes.YourCause, payeeOverride: 'payeeOverrideInfo' in payment ? payment.payeeOverrideInfo : null, organizationEligibleForGivingStatus: 'organizationEligibleForGivingStatus' in award ? award.organizationEligibleForGivingStatus : null, batchName: 'batchName' in payment ? payment.batchName : '' }; }) }; }); } async handleAwardModalUpdates ( response: AwardModalPayload, applicationId: number ): Promise { let awardId = response.id; const data: AwardForApi = { inKindAwardItemsRequested: response.awardedItems.map((item) => { return { count: +item.unitsEntered, itemIdentification: item.identification, value: item.value }; }), awardType: response.awardType, id: awardId, amount: response.amount, description: response.description, sendEmail: response.sendEmail, awardDate: response.awardDate, clientEmailTemplateId: response.clientEmailTemplateId, currencyRequested: response.currencyRequested, amountEquivalent: response.amountEquivalent, currencyExchangeRate: response.currencyExchangeRate ? response.currencyExchangeRate.id : null, customMessage: response.customMessage, emailOptionsRequest: response.emailOptionsModel }; try { const awardResponse = await this.awardResources.saveAward(applicationId, data); if (awardResponse.automaticallyRouted) { this.notifier.success(this.i18n.translate( 'APPLICATION:textRoutingRuleCriteriaMetApplication', {}, 'Routing rule criteria met. Application has been routed to the next workflow level.' )); } awardId = awardResponse.awardId; const removedPayment = response.removedPayment; if (removedPayment) { await this.awardResources.removePayment( applicationId, awardId, removedPayment.id ); } const updatedPayment = response.updatedPayment; if (updatedPayment) { await this.handleUpdatedPayment(applicationId, awardId, updatedPayment, response); } const addedPayment = response.addedPayment; if (addedPayment) { await this.handleAddedPayment( addedPayment, response.paidItems, response, applicationId, awardId ); } this.programService.clearPaymentProcessingPrograms(); await this.handleTagUpdates(response); if (response.usedOverage) { await this.budgetService.resetFundingSources(); } this.notifier.success(this.i18n.translate( 'AWARDS:textSuccessfullySavedAward', {}, 'Successfully saved the award' )); return awardId; } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'AWARDS:textErrorSavingAward', {}, 'There was an error saving the award' )); return null; } } private async handleUpdatedPayment ( applicationId: number, awardId: number, updatedPayment: Payment, response: AwardModalPayload ) { const res = await this.awardResources.adjustPayment( applicationId, awardId, updatedPayment.id, { budgetId: updatedPayment.budget, fundingSourceId: updatedPayment.fundingSource, scheduledDate: updatedPayment.scheduledDate, notes: updatedPayment.notes, value: +updatedPayment.amount, paymentDesignation: TextFriendlySpecialCharCleaner(updatedPayment.paymentDesignation), valueEquivalent: +updatedPayment.currencyRequestedAmountEquivalent, currencyRequested: updatedPayment.currencyRequested, currencyExchangeRate: response.currencyExchangeRate ? response.currencyExchangeRate.id : null, differentThanConversion: updatedPayment.differentThanConversion, careOf: updatedPayment.careOf, inKindPaymentItemsRequested: response.paidItems.map((item) => { return { count: +item.unitsEntered, itemIdentification: item.identification, value: item.value }; }) } ); if (res.automaticallyRouted) { this.notifier.success(this.i18n.translate( 'APPLICATION:textRoutingRuleCriteriaMetApplication', {}, 'Routing rule criteria met. Application has been routed to the next workflow level.' )); } } async removePayment ( applicationId: number, awardId: number, paymentId: number ) { try { await this.awardResources.removePayment( applicationId, awardId, paymentId ); this.programService.clearPaymentProcessingPrograms(); this.notifier.success(this.i18n.translate( 'AWARDS:textSuccessfullyDeletedPayment', {}, 'Successfully deleted the payment' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'AWARDS:textErrorDeletingPayment', {}, 'There was an error deleting the payment' )); } } async handleDeleteAward ( row: Award, applicationId: number ) { try { await this.awardResources.deleteAward(applicationId, row.id); this.notifier.success(this.i18n.translate( 'AWARDS:textSuccessfullyDeletedAward', {}, 'Successfully deleted the award' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'AWARDS:textErrorDeletingAward', {}, 'There was an error deleting the award' )); } } private async handleAddedPayment ( addedPayment: Payment, paidItems: InKindItemToAwardOrPay[] = [], response: AwardModalPayload, applicationId: number, awardId: number ) { const adapted = this.adaptPaymentForApi( addedPayment, paidItems, response.currencyExchangeRate ); const res = await this.awardResources.createPayment( applicationId, awardId, adapted ); addedPayment.id = res.paymentId; if (res.automaticallyRouted) { this.notifier.success(this.i18n.translate( 'APPLICATION:textRoutingRuleCriteriaMetApplication', {}, 'Routing rule criteria met. Application has been routed to the next workflow level.' )); } if (addedPayment.tags && addedPayment.tags.length) { await this.systemTagsService.setTagsOnRecord( SystemTags.Buckets.Payment, addedPayment.id, addedPayment.tags, [] ); } } private async handleTagUpdates (response: AwardModalPayload) { await Promise.all(Object.keys(response.tagMap).map(async (paymentId: string) => { const tagChanges = response.tagMap[ +paymentId ]; const isDeletedPayment = response.removedPayment && +response.removedPayment.id === +paymentId; if (!isDeletedPayment && (tagChanges.removed.length || tagChanges.added.length)) { await this.systemTagsService.setTagsOnRecord( SystemTags.Buckets.Payment, paymentId, uniq(tagChanges.added), uniq(tagChanges.removed) ); } })); } adaptPaymentForApi ( payment: Payment, paidItems: InKindItemToAwardOrPay[] = [], exchangeRate: ExchangeRate ): PaymentForApi { return { budgetId: payment.budget, scheduledDate: this.timeZoneService.returnMidnightUTCDate(payment.scheduledDate), fundingSourceId: payment.fundingSource, notes: payment.notes, paymentDesignation: TextFriendlySpecialCharCleaner(payment.paymentDesignation), totalAmount: +payment.amount, fundingSourceType: payment.isUnits ? FundingSourceTypes.UNITS : FundingSourceTypes.DOLLARS, currencyExchangeRate: exchangeRate ? exchangeRate.id : null, totalAmountEquivalent: +payment.currencyRequestedAmountEquivalent, differentThanConversion: payment.differentThanConversion, currencyRequested: payment.currencyRequested, inKindPaymentItemsRequested: paidItems.map((item) => { return { count: +item.unitsEntered, itemIdentification: item.identification, value: item.value }; }) }; } shouldHideAwardTab ( application: ApplicationForUi, awards: AwardFromApi[], isNomination = false ) { const levelHasAwards = application.currentWorkflowLevelAllowAward; const levelAllowsViewAwards = application.currentWorkflowLevelAllowUserToViewAwardsAndPayments; const noAwardsExist = awards?.length === 0; return noAwardsExist || isNomination || ((!application.canAwardApplication && !levelHasAwards) && !levelAllowsViewAwards); } async getTopCyclesByAward ( cycleIds: number[] ) { const cycles = await this.awardResources.getTopCycles( cycleIds ); cycles.forEach((item) => { const map = this.translationService.viewTranslations.Grant_Program_Cycle[item.cycleId]; item.cycleName = map && map.Name ? map.Name : ''; }); const sorted = this.arrayHelper.sortByAttributes( cycles.filter((item) => item.cycleId !== 0), 'awardAmount', 'numberOfAwards', true ); return sorted; } sumPayments ( payments: Partial[], returnDefault = true ) { return payments .filter((payment) => payment.statusId !== PaymentStatus.Voided) .reduce((amount, payment) => { if (returnDefault || !payment.currencyRequestedAmountEquivalent) { return +amount + +payment.amount; } return +amount + +payment.currencyRequestedAmountEquivalent; }, 0); } shouldDisableCreateAward ( canAwardApplication: boolean, programBudgets: number[], existingAwardTypes: FundingSourceTypes[], budgetsFromState: Budget[] ) { if (!canAwardApplication) { return false; } let disableCreateAward = true; let allowUnits = false; let allowCash = false; let hasUnits = false; let hasCash = false; budgetsFromState.filter((budget) => { return programBudgets.includes(budget.id); }).forEach((budget) => { if (budget.fundingSourceType === FundingSourceTypes.DOLLARS) { allowCash = true; } if (budget.fundingSourceType === FundingSourceTypes.UNITS) { allowUnits = true; } }); existingAwardTypes.forEach((type) => { if ( !type || (type === FundingSourceTypes.DOLLARS) ) { hasCash = true; } if (type === FundingSourceTypes.UNITS) { hasUnits = true; } }); if (allowUnits && !hasUnits) { disableCreateAward = false; } else if (allowCash && !hasCash) { disableCreateAward = false; } return disableCreateAward; } async handleApproveAwardPayModal ( modalResponse: ApproveAwardPayModalPayload, isAppManager = false ) { try { const attachments = await this.emailService.returnIdsFromMixedAttachments( modalResponse.emailOptionsModel.attachments, modalResponse.applicationId ); const emailOptionsModel = { ...modalResponse.emailOptionsModel, attachments }; const payload: ApproveAwardPayPayload = { ...modalResponse, emailOptions: emailOptionsModel }; await this.awardResources.approveAwardAndPayApplication( payload, isAppManager ); if (payload.usedOverage) { await this.budgetService.resetFundingSources(); } this.notifier.success(this.i18n.translate( 'AWARDS:textSuccessfullyApproveAwardPay2', {}, 'Successfully approved and awarded the application' )); return true; } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'AWARDS:textThereWasAnErrorApproveAwardPay', {}, 'There was an error approving and awarding the application' )); return false; } } // ** Start Simple Award Modal Helpers ** // getAwardType ( allowCash: boolean, allowUnits: boolean, originalAward: Award, existingAwardTypes: FundingSourceTypes[], defaultToDollars = true ): { awardType: FundingSourceTypes; isAwardTypeView: boolean; } { let awardType = originalAward ? originalAward.awardType : null; let isAwardTypeView = false; if (!awardType) { let hasCash = false; let hasUnits = false; existingAwardTypes.forEach((type) => { if (!type || (type === FundingSourceTypes.DOLLARS)) { hasCash = true; } if (type === FundingSourceTypes.UNITS) { hasUnits = true; } }); if (allowCash && allowUnits && !hasCash && !hasUnits) { isAwardTypeView = true; awardType = defaultToDollars ? FundingSourceTypes.DOLLARS : null; } else if (allowCash && !hasCash) { awardType = FundingSourceTypes.DOLLARS; } else { awardType = FundingSourceTypes.UNITS; } } return { awardType, isAwardTypeView }; } getAwardAmountForFormGroup ( amount: number, currencyRequested: string, budgetOrSourceClosed = false ) { if (budgetOrSourceClosed) { return '0'; } let returnVal: string; returnVal = this.currencyService.fixNumberOfDecimals(amount, currencyRequested); returnVal = this.maskingService.maskNumberDecimal( returnVal, this.currencyService.formattingData[currencyRequested] ); return returnVal; } getAmountInRequested ( amount: string, currencyRequested: string ) { const unmasked = this.getUnmaskedAmount(amount, currencyRequested); return +unmasked.toFixed(this.precisionMap[currencyRequested]); } getUnmaskedAmount ( amount = '0', currency: string ) { return this.maskingService.unmaskNumber( this.currencyService.formattingData[currency], amount ); } unmaskAndConvertFromRequestedToDefault ( amount: string, currency: string, rate = 1 ) { const converted = this.getUnmaskedAmount(amount, currency) * rate; return +converted.toFixed( this.precisionMap[this.clientSettingsService.defaultCurrency] ); } getSumOfAllPayments ( currentAmount: string, currencyRequested: string, originalPaymentsSumInDefault: number, originalPaymentsSumInRequested: number, showConversions = false, inDefault = true, rate = 1 ) { const otherPaymentsSum = inDefault ? originalPaymentsSumInDefault : originalPaymentsSumInRequested; let currentPaymentAmount: number; const currency = showConversions && inDefault ? this.clientSettingsService.defaultCurrency : currencyRequested; if (showConversions && inDefault) { currentPaymentAmount = this.unmaskAndConvertFromRequestedToDefault( currentAmount, currencyRequested, rate ); } else { currentPaymentAmount = this.getUnmaskedAmount(currentAmount, currencyRequested); } const sum = currentPaymentAmount + otherPaymentsSum; return currentPaymentAmount = +sum.toFixed( this.precisionMap[currency] ); } getAmountsForPaymentControl ( payment: Payment, useEquivalent: boolean, currencyRequested: string, isUnits: boolean, rate = 1 ) { const amount = this.currencyService.fixNumberOfDecimals( useEquivalent ? +payment.currencyRequestedAmountEquivalent : +payment.amount, currencyRequested ); let conversion: string = null; if (!isUnits) { conversion = this.currencyService.fixNumberOfDecimals( +payment.amount, this.clientSettingsService.defaultCurrency ); let conversionAmount = +payment.amount; if ( useEquivalent && !payment.differentThanConversion && [PaymentStatus.Pending, PaymentStatus.Scheduled].includes(payment.statusId) ) { conversionAmount = +payment.currencyRequestedAmountEquivalent * rate; } conversion = this.currencyService.fixNumberOfDecimals( conversionAmount, this.clientSettingsService.defaultCurrency ); } return { amount: this.maskingService.maskNumberDecimal( amount, this.currencyService.formattingData[currencyRequested] ), conversion: !isUnits ? this.maskingService.maskNumberDecimal( conversion, this.currencyService.formattingData[this.clientSettingsService.defaultCurrency] ) : null }; } async getAvailableAwardTypes ( programId: number, cycleId: number, cycle?: CyclesUI.ProgramCycle ) { if (!cycle) { cycle = await this.programService.getCycleFromProgram(programId, cycleId); } const availableTypes = this.budgetService.get('budgets') .filter(budget => cycle.budgetIds.includes(budget.id)) .map((budget) => { const isCash = budget.fundingSourceType === FundingSourceTypes.DOLLARS; if (isCash) { return FundingSourceTypes.DOLLARS; } else { return FundingSourceTypes.UNITS; } }); return uniq(availableTypes.filter((type) => !!type)); } handlePaymentSourceValidation ( budgetFs: BudgetFundingSourceCombo, paymentAmount: string, remaining: number, currencyRequested: string, rate = 1, isUnits = false, allowOverage = true, skipClosedLogic = false ): { error: any; canMoveFundsText: string; } { const cantExceedAvail = { amount: { // control name amountExceeded: { // type of error i18nKey: 'AWARDS:textCannotExceedAmountAvailableDesc', defaultValue: 'Cannot exceed amount available' } } }; const isClosed = !skipClosedLogic && budgetFs.isClosed; if ( !isClosed && allowOverage && this.clientSettingsService.clientSettings.allowBudgetOverages ) { const availToMove = this.budgetService.unallocatedSourceMap[ budgetFs.fundingSource.fundingSourceId ]; if (!availToMove || availToMove <= 0) { return { error: cantExceedAvail, canMoveFundsText: '' }; } else { const canCoverAll = (remaining + availToMove) >= 0; if (canCoverAll) { return { error: null, canMoveFundsText: this.i18n.translate( 'AWARDS:textMoveFundsDescCanCoverAll', { overageAmount: this.currencyService.formatMoney( Math.abs(remaining) ), fundingSourceName: budgetFs.fundingSource.fundingSourceName, budgetName: budgetFs.budget.name, unallocatedAmount: this.currencyService.formatMoney(availToMove) }, `This payment will result in a __overageAmount__ overage for __fundingSourceName__ funding source. The source currently has __unallocatedAmount__ in unallocated funds. Click 'Yes' to move funds from the funding source to the __budgetName__ budget to cover and create this payment.` ) }; } else if (isUnits) { return { error: cantExceedAvail, canMoveFundsText: '' }; } else { // Partial funds available const oldPaymentAmount = this.unmaskAndConvertFromRequestedToDefault( paymentAmount, currencyRequested, rate ); const overBy = Math.abs(remaining); const newPaymentAmount = this.getNewPaymentAmountFromMovingFunds( budgetFs, remaining, paymentAmount, currencyRequested, rate ); return { error: null, canMoveFundsText: this.i18n.translate( 'AWARDS:textMoveFundsDescCanCoverPartial', { overageAmount: this.currencyService.formatMoney( overBy ), fundingSourceName: budgetFs.fundingSource.fundingSourceName, budgetName: budgetFs.budget.name, unallocatedAmount: this.currencyService.formatMoney(availToMove), newPaymentAmount: this.currencyService.formatMoney( newPaymentAmount ), oldPaymentAmount: this.currencyService.formatMoney( oldPaymentAmount ) }, `This payment will result in a __overageAmount__ overage for __fundingSourceName__ funding source. This source currently has __unallocatedAmount__ in unallocated funds. This is not enough to cover the full payment. Click 'Yes' to move the remaining unallocated funds from the __fundingSourceName__ source to the __budgetName__ budget to maximize this payment. The payment amount will change from __oldPaymentAmount__ to __newPaymentAmount__.` ) }; } } } else { return { error: cantExceedAvail, canMoveFundsText: '' }; } } getNewPaymentAmountFromMovingFunds ( budgetFs: BudgetFundingSourceCombo, remaining: number, paymentAmount: string, currency: string, rate = 1 ) { const availToMove = this.budgetService.unallocatedSourceMap[ budgetFs.fundingSource.fundingSourceId ]; const oldPaymentAmount = this.unmaskAndConvertFromRequestedToDefault( paymentAmount, currency, rate ); const overBy = Math.abs(remaining); const difference = overBy - availToMove; return oldPaymentAmount - difference; } // ** End Simple Award Modal Helpers ** // // ** Start Simple Approve/Award/Pay Helpers ** // async prepareApproveAwardPayModal ( workflowId: number, levelId: number, programId: number, cashAmountRequested: number, inKindAmountRequested: number, isAppManager = false, organizationEligibleForGivingStatus: OrganizationEligibleForGivingStatus, cycleId: number, recommendedFundingAmount: number, isArchived: boolean ): Promise { if (isArchived) { return false; } const cycle = await this.programService.getCycleFromProgram(programId, cycleId); const passesVettingReq = this.checkVettingRequirementForAward( organizationEligibleForGivingStatus, cycle.isClientProcessing ? ProcessingTypes.Client : ProcessingTypes.YourCause, cycle.budgetIds, cashAmountRequested, inKindAmountRequested, recommendedFundingAmount, !isAppManager ); const availableAwardTypes = await this.getAvailableAwardTypes( programId, cycleId, cycle ); const canCreateAward = this.hasClientProcessorsToCreateAward( passesVettingReq, cycle.budgetIds, availableAwardTypes, [] ); if (!canCreateAward) { return false; } const workflow = await this.workflowService.getAndSetWorkflowMap( workflowId ); let detailedLevel: WorkflowLevel; workflow.levels.forEach(desLevel => { if (levelId === desLevel.id) { detailedLevel = desLevel; } desLevel.subLevels.forEach((sub) => { if (levelId === sub.id) { detailedLevel = sub; } }); }); if (detailedLevel && (detailedLevel.allowAward || isAppManager)) { let passed = false; if (availableAwardTypes.includes(FundingSourceTypes.DOLLARS)) { if (!!cashAmountRequested || !!recommendedFundingAmount) { passed = true; } } if (availableAwardTypes.includes(FundingSourceTypes.UNITS)) { if (!!inKindAmountRequested || inKindAmountRequested === 0) { passed = true; } } return passed; } return false; } getCycleBudgetIds (id: number) { return this.programService.get('cycleBudgetsMap')[id] || []; } async prepareApproveAwardPayModalBulk ( rows: ApplicationFromPaginated[], programId: number, cycleId: number ): Promise { const currency = rows.map((row) => row.currencyRequested); const uniqCurrencies = uniq(currency); if (uniqCurrencies.length !== 1) { return false; } const cycle = await this.programService.getCycleFromProgram(programId, cycleId); const passedVettingArray = rows.map((app) => { return this.checkVettingRequirementForAward( app.organizationEligibleForGivingStatus, cycle.isClientProcessing ? ProcessingTypes.Client : ProcessingTypes.YourCause, this.getCycleBudgetIds(app.grantProgramCycle.id), app.amountRequested, app.inKindAmountRequested, app.recommendedFundingAmount ); }); const availableAwardTypes = await this.getAvailableAwardTypes( programId, cycleId, cycle ); let canCreateAward = true; passedVettingArray.forEach((passed) => { const canCreate = this.hasClientProcessorsToCreateAward( passed, cycle.budgetIds, availableAwardTypes, [] ); if (!canCreate) { canCreateAward = false; } }); if (!canCreateAward) { return false; } const hasCash = availableAwardTypes.includes(FundingSourceTypes.DOLLARS); const hasInKind = availableAwardTypes.includes(FundingSourceTypes.UNITS); let failed = false; rows.forEach((row) => { const requestedCash = !!row.amountRequested || !!row.recommendedFundingAmount; const requestedInKind = !!(row.inKindAmountRequested || row.inKindAmountRequested === 0); if (requestedCash && requestedInKind) { // do nothing; } else if (requestedCash) { if (!hasCash) { failed = true; } } else if (requestedInKind) { if (!hasInKind) { failed = true; } } else { failed = true; } }); return !failed; } checkVettingRequirementForAward ( organizationEligibleForGivingStatus: OrganizationEligibleForGivingStatus, clientOrganizationsProcessingTypeId: ProcessingTypes, cycleBudgets: number[], amountRequested: number, inKindRequested: number, recommendedFundingAmount: number, isSimpleAward = false // in this case, amount requested values are not required (whle they ARE necessary for bulk) ): boolean { const isEligible = organizationEligibleForGivingStatus === OrganizationEligibleForGivingStatus.ELIGIBLE; const nonVettedProcessingwithYC = clientOrganizationsProcessingTypeId === ProcessingTypes.YourCause; const { clientCashBudgets, clientInKindBudgets } = this.getClientCycleBudgets(cycleBudgets); if (!isEligible) { if (nonVettedProcessingwithYC) { return false; } if (!isSimpleAward) { if (!!amountRequested || !!recommendedFundingAmount) { return clientCashBudgets.length > 0; } if (!!inKindRequested) { return clientInKindBudgets.length > 0; } } else { return clientCashBudgets.length > 0 || clientInKindBudgets.length > 0; } } return true; } getClientCycleBudgets (cycleBudgets: number[]) { const ycCashBudgets: Budget[] = []; const clientCashBudgets: Budget[] = []; const ycInKindBudgets: Budget[] = []; const clientInKindBudgets: Budget[] = []; cycleBudgets.forEach((budget) => { const found = this.budgetService.get('simpleBudgetMap')[budget]; if (found.fundingSourceType === FundingSourceTypes.DOLLARS) { if (found.hasClientProcessingType) { clientCashBudgets.push(found); } if (found.hasYcProcessingType) { ycCashBudgets.push(found); } } else { if (found.hasClientProcessingType) { clientInKindBudgets.push(found); } if (found.hasYcProcessingType) { ycInKindBudgets.push(found); } } }); return { clientCashBudgets, clientInKindBudgets }; } getCreatePaymentAlertHelper ( processorIsYc = false, hasSpecialHandling = false, skipStandardText = false ) { const standardText = skipStandardText ? '' : this.i18n.translate( 'AWARDS:textCreateAPaymentDesc', {}, 'Create a payment for this award. Payments represent the total amount that the applicant will receive.' ); if (hasSpecialHandling && processorIsYc) { return standardText + ' ' + this.i18n.translate( 'GLOBAL:textAnAlternateAddressRequestWasCreatedForThisAppHelp', {}, 'An alternate address request was created for this application. Payments processed by YourCause cannot be processed until the request is approved by Blackbaud Compliance.' ); } else { return standardText; } } // ** End Simple Approve/Award/Pay Helpers ** // hasClientProcessorsToCreateAward ( passesVetting: boolean, cycleBudgets: number[], availableAwardTypes: FundingSourceTypes[], existingAwardTypes: FundingSourceTypes[] ) { const hasAvailableType = availableAwardTypes.length > existingAwardTypes.length; let canCreateAward = passesVetting && hasAvailableType; if (!passesVetting && hasAvailableType) { // If they don't pass vetting, see if there are client funding sources available // to create payments with const { clientCashBudgets, clientInKindBudgets } = this.getClientCycleBudgets(cycleBudgets); availableAwardTypes.forEach((type) => { if (type === FundingSourceTypes.DOLLARS) { if (clientCashBudgets.length > 0) { canCreateAward = true; } } else { if (clientInKindBudgets.length > 0) { canCreateAward = true; } } }); } return canCreateAward; } async getAwardListForInsights ( options: PaginationOptions, cycleIds: number[], isCsv: C ): Promise> { if (isCsv) { this.spinnerService.startSpinner(); } const result = await this.awardResources.getAwardsForInsights( cycleIds, options, false, isCsv ); const apiResult: APIResult = { success: true, data: { recordCount: result.recordCount, records: result.records } }; let csvResult: string; if (isCsv) { csvResult = this.processCsvForAwardList(result.records); this.spinnerService.stopSpinner(); } return (isCsv ? csvResult : apiResult) as (C extends true ? string : APIResult); } processCsvForAwardList (records: AwardForDash[]) { const adapted = records.map((record) => { const applicantInfo = record.applicant; const orgInfo = record.organization; const isMasked = record.isMasked && !record.canViewMaskedApplicantInfo; const maskedText = '******'; return { 'Award ID': record.award.id, 'Award Amount': record.award.amount, 'Created By': record.award.createByName, 'Created Date': this.timeZoneService.displayFormattedTimeInTimezone( record.award.createdDate, null, true ), 'Award Date': this.timeZoneService.displayFormattedTimeInTimezone( record.award.awardDate ), 'Award Remaining': record.award.awardRemaining, 'Application ID': record.award.associatedApplicationId, 'In Kind Items': record.award.inKindItems.map((item) => { return `${item.itemIdentification} (${item.count})`; }).join(', '), 'Program ID': record.program.id, 'Program Name': record.program.name, 'Applicant ID': isMasked ? maskedText : applicantInfo.id, 'Applicant Name': isMasked ? maskedText : applicantInfo.fullName, 'Applicant Email': isMasked ? maskedText : applicantInfo.email, 'Applicant Phone': isMasked ? maskedText : applicantInfo.phoneNumber, 'Applicant Address 1': isMasked ? maskedText : applicantInfo.address1, 'Applicant Address 2': isMasked ? maskedText : applicantInfo.address2, 'Applicant City': isMasked ? maskedText : applicantInfo.city, 'Applicant State': isMasked ? maskedText : applicantInfo.state, 'Applicant Postal Code': isMasked ? maskedText : applicantInfo.postalCode, 'Applicant Country': isMasked ? maskedText : applicantInfo.country, 'Organization Name': orgInfo?.name || '', 'Registration ID': orgInfo?.identification || '' }; }); return this.fileService.convertObjectArrayToCSVString(adapted); } getPaymentTypeOptions (): TypeaheadSelectOption[] { return this.arrayHelper.sort([{ label: this.i18n.translate( 'GLOBAL:texCheck', {}, 'Check' ), value: PaymentType.Check }, { label: this.i18n.translate( 'GLOBAL:textACH', {}, 'ACH' ), value: PaymentType.ACH }, { label: this.i18n.translate( 'common:textWireTransfer', {}, 'Wire transfer' ), value: PaymentType.WireTransfer }, { label: this.i18n.translate( 'common:textOther', {}, 'Other' ), value: PaymentType.Other }], 'label'); } getPaymentTypeMap () { const options = this.getPaymentTypeOptions(); return options.reduce>((acc, opt) => { return { ...acc, [opt.value]: opt.label }; }, {} as any); } /** * Returns the number of non voided payments tied to an application * * @param appId: application ID * @returns the number of non voided payments */ async getNumberOfNonVoidedPaymentsByAppId (appId: number) { const { awards } = await this.getAwards(appId); // Can cancel if no payments or if all payments are voided let numberOfNonVoidedPayments = 0; awards.forEach((award) => { award.payments.forEach((payment) => { if (payment.status !== PaymentStatus.Voided) { numberOfNonVoidedPayments = numberOfNonVoidedPayments + 1; } }); }); return numberOfNonVoidedPayments; } /** * Should payment amount be disabled in the Simple Award Modal? * * @param isUnits: is this for in-kind? * @param isExistingPayment: is this an existing payment? * @param paymentStatus: payment's status * @param isEditingConversion: are we editing the conversion? * @param isUpdatingBudget: are we updating the budget? * @param budgetFsIsClosed: is the budget or fs closed? * @returns if the payment amount control should be disabled */ getIsPaymentAmountDisabled ( isUnits: boolean, isExistingPayment: boolean, paymentStatus: PaymentStatus, isEditingConversion: boolean, isUpdatingBudget: boolean, budgetFsIsClosed: boolean ) { if (isUnits || isEditingConversion) { return true; } else if (isExistingPayment) { return paymentStatus !== PaymentStatus.Pending || (budgetFsIsClosed && !isUpdatingBudget); } return false; } /** * Returns text about the awards for a given application * * @param awards: Awards for applicant * @returns award total text */ getAwardTotalText (awards: MyAward[]) { const inKindAward = awards.find((award) => { return award.awardType === FundingSourceTypes.UNITS; }); const cashAward = awards.find((award) => { return award.awardType === FundingSourceTypes.DOLLARS; }); let totalCash; let totalUnits; if (cashAward) { totalCash = this.currencyService.formatMoney( cashAward.currencyRequestedAmountEquivalent || cashAward.amount, cashAward.currencyRequested ); } if (inKindAward) { totalUnits = inKindAward?.inKindItems.reduce((acc, award) => { return acc + +award.count; }, 0); } if (inKindAward && cashAward) { return this.i18n.translate( 'common:textYouHaveBeenAwardedCashAndInKind', { cashAmount: totalCash, numberOfUnits: totalUnits }, 'You have been awarded __cashAmount__ and __numberOfUnits__ units.' ); } else if (cashAward) { return this.i18n.translate( 'common:textYouHaveBeenAwardedCash', { cashAmount: totalCash }, 'You have been awarded __cashAmount__.' ); } else if (inKindAward) { return this.i18n.translate( 'common:textYouHaveBeenAwardedInKind', { numberOfUnits: totalUnits }, 'You have been awarded __numberOfUnits__ units.' ); } return ''; } }