import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { Router } from '@angular/router'; import { SpecialHandlingModalComponent } from '@core/components/special-handling-modal/special-handling-modal.component'; import { SpecialHandlingService } from '@core/services/special-handling.service'; import { SpinnerService } from '@core/services/spinner.service'; import { StatusService } from '@core/services/status.service'; import { TranslationService } from '@core/services/translation.service'; import { FundingSourceTypes } from '@core/typings/budget.typing'; import { ApplicantInfo, ExpeditePaymentForApi, OrganizationInfo, PaymentForProcess, ProcessingTypes } from '@core/typings/payment.typing'; import { BatchStatuses, PaymentStatus } from '@core/typings/status.typing'; import { ApplicantManagerService } from '@features/applicant/applicant-manager.service'; import { AwardResources } from '@features/awards/award.resources'; import { BudgetService } from '@features/budgets/budget.service'; import { ClientSettingsService } from '@features/client-settings/client-settings.service'; import { CyclesService } from '@features/cycles/cycles.service'; import { NonprofitService } from '@features/nonprofit/nonprofit.service'; import { DesignationModalComponent } from '@features/payment-processing/designation-modal/designation-modal.component'; import { ExpeditePaymentModalComponent } from '@features/payment-processing/expedite-payment-modal/expedite-payment-modal.component'; import { HoldPaymentModalComponent } from '@features/payment-processing/hold-payment-modal/hold-payment-modal.component'; import { PaymentDetailsModalComponent } from '@features/payment-processing/payment-details-modal/payment-details-modal.component'; 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 { AddressFormatterService, AutoTableRepositoryFactory, BulkAction, OrganizationEligibleForGivingStatus, SimpleUpdateModalComponent, TableDataFactory, TextFriendlySpecialCharCleaner, TopLevelFilter, TypeaheadSelectOption } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { ConfirmationModalComponent, ModalFactory } from '@yourcause/common/modals'; import { NotifierService } from '@yourcause/common/notifier'; import { uniqBy } from 'lodash'; import { NotifyPayeesModalComponent } from '../notify-payees-modal/notify-payees-modal.component'; import { PaymentProcessingResources } from '../payment-processing.resources'; import { PaymentProcessingService } from '../payment-processing.service'; import { PaymentRecallModalComponent } from '../payment-recall-modal/payment-recall-modal.component'; import { UpdatePaymentsModalComponent } from '../update-payments-modal/update-payments-modal.component'; @Component({ selector: 'gc-payments-table', templateUrl: './payments-table.component.html', styleUrls: ['./payments-table.component.scss'] }) export class PaymentsTableComponent implements OnChanges, AfterViewInit { @Input() isInKind = false; @Input() inKindStatus: PaymentStatus.Pending|PaymentStatus.Fulfilled; @Input() isAvailable = true; @Input() isYourCause = true; @Input() isIncluded = true; @Input() batchId: number; @Input() batchStatus: BatchStatuses; @Input() programIds: number[]; @Input() programOptions: TypeaheadSelectOption[]; @Input() workflowLevelOptions: TypeaheadSelectOption[] = this.workflowService.myWorkflowLevelOptions; @Output() onBatchChanged = new EventEmitter(); OrganizationEligibleForGivingStatus = OrganizationEligibleForGivingStatus; FundingSourceTypes = FundingSourceTypes; fromRoute = this.applicantManagerService.getFromRouteForProfile(); hiddenFilters: TopLevelFilter[] = []; topLevelFilters: TopLevelFilter[] = []; paymentStatusMap = this.statusService.paymentStatusMap; tableDataFactory: TableDataFactory; BatchStatuses = BatchStatuses; PaymentStatuses = PaymentStatus; tableKey: string; clientSettings = this.clientSettingsService.get('clientSettings'); bulkActions: BulkAction[] = []; cycleOptions = this.cyclesService.allMyNonFutureCycleSelectOptions; ProcessingTypes = ProcessingTypes; tagOptions = uniqBy( this.systemTagsService.getTagsForBucket(SystemTags.Buckets.Payment, true) .concat(this.systemTagsService.getTagsForBucket(SystemTags.Buckets.Application, true)) .concat(this.systemTagsService.getTagsForBucket(SystemTags.Buckets.NonprofitProfile, true)), 'id' ).map(tag => { return { label: tag.name, value: tag.id }; } ); budgetOptions: TypeaheadSelectOption[] = []; sourceOptions: TypeaheadSelectOption[] = []; paymentStatusOptions: TypeaheadSelectOption[] = []; viewTranslations = this.translationService.viewTranslations; programTranslationMap = this.viewTranslations.Grant_Program; cycleTranslationMap = this.viewTranslations.Grant_Program_Cycle; defaultCurrency = this.clientSettingsService.defaultCurrency; showSpecialHandling = this.clientSettingsService.clientSettings.specialHandling; closedText = ' (' + this.i18n.translate( 'GLOBAL:textClosed', {}, 'Closed' ) + ') '; ProcessorTypes = ProcessingTypes; constructor ( private logger: LogService, private systemTagsService: SystemTagsService, private paymentProcessingService: PaymentProcessingService, private paymentProcessingResources: PaymentProcessingResources, private statusService: StatusService, private modalFactory: ModalFactory, private i18n: I18nService, private spinnerService: SpinnerService, private notifier: NotifierService, private clientSettingsService: ClientSettingsService, private addressFormatter: AddressFormatterService, private router: Router, private autoTableFactory: AutoTableRepositoryFactory, private budgetService: BudgetService, private applicantManagerService: ApplicantManagerService, private nonprofitService: NonprofitService, private awardResources: AwardResources, private translationService: TranslationService, private cyclesService: CyclesService, private workflowService: WorkflowService, private specialHandlingService: SpecialHandlingService ) { } get applicantRouterLink () { return this.applicantManagerService.get('applicantProfileRouterLink'); } get nonprofitRouterLink () { return this.nonprofitService.get('nonprofitProfileRouterLink'); } get hideTableFilters () { return !this.isInKind && this.isAvailable && !this.isIncluded; } ngOnChanges () { const statusOptions = this.statusService.paymentStatusOptions; if (this.isInKind) { this.paymentStatusOptions = statusOptions.filter((option) => { return [ PaymentStatus.Pending, PaymentStatus.Fulfilled ].includes(option.value); }); this.budgetOptions = this.budgetService.inKindBudgetOptions; this.sourceOptions = this.budgetService.inKindSourceOptions; } else { this.paymentStatusOptions = statusOptions.filter((option) => { return ![ PaymentStatus.Fulfilled ].includes(option.value); }); this.budgetOptions = this.budgetService.cashBudgetOptions; this.sourceOptions = this.budgetService.cashSourceOptions; } if (!this.tableDataFactory) { this.setTableData(); } this.resetTableAndFilters(); } ngAfterViewInit () { this.resetTableAndFilters(); } resetTableAndFilters () { this.setFilters(); this.setHiddenFilters(); this.setBulkActions(); this.setTableKey(); } setTableKey () { this.tableKey = undefined; setTimeout(() => { let key = ''; const programIds = (this.programIds || []).join(','); if (this.isInKind) { key = `IN_KIND_${this.inKindStatus}_${programIds}`; } else { if (this.isAvailable) { key = `AVAILABLE_${ this.isIncluded ? 'INCLUDED' : 'EXCLUDED' }_${ this.isYourCause ? 'YOURCAUSE' : 'CLIENT' }_${programIds}`; } else { key = `BATCH_${ this.isIncluded ? 'INCLUDED' : 'EXCLUDED' }_${this.batchId}_${programIds}`; } } const repo = this.autoTableFactory.getRepository(key); if (repo) { repo.reset(); } this.tableKey = key; }); } setBulkActions () { this.bulkActions = []; const showUpdatePayments = this.isInKind || (this.batchId && !this.isYourCause); if (showUpdatePayments) { this.bulkActions = [{ label: this.i18n.translate( 'MANAGE:textUpdatePayments', {}, 'Update payments' ), exec: (arg) => this.updatePayments(arg) }]; } const showNotifyPayee = this.batchStatus && ![ BatchStatuses.Open, BatchStatuses.Reviewed ].includes(this.batchStatus); if (showNotifyPayee) { this.bulkActions = [ ...this.bulkActions, { label: this.i18n.translate( 'MANAGE:textNotifyPayees', {}, 'Notify payees' ), exec: (arg) => this.notifyPayeesModal(arg) } ]; } } setFilters () { this.topLevelFilters = [ new TopLevelFilter( 'text', 'applicantInfo.fullName', '', this.i18n.translate( 'MANAGE:textSearchByApplicantOrgNameAppID', {}, 'Search by applicant, organization name, or application ID' ), undefined, undefined, [{ column: 'applicantInfo.fullName', filterType: 'cn' }, { column: 'organizationInfo.name', filterType: 'cn' }, { column: 'applicationId', filterType: 'eq' }] ) ]; if (this.isIncluded) { this.topLevelFilters = [ ...this.topLevelFilters, new TopLevelFilter( 'dateRange', 'scheduledDate', '', this.isInKind ? this.i18n.translate( 'GLOBAL:lblPaymentDate', {}, 'Payment date' ) : this.i18n.translate( 'GLOBAL:textAvailableToProcess', {}, 'Available to process' ) ) ]; } } setTableData () { this.tableDataFactory = this.paymentProcessingService.getTableDataFactory( this.batchId, this.isInKind ); } resetCurrentRepo () { const repo = this.autoTableFactory.getRepository(this.tableKey); if (repo) { repo.reset(); } } formatAddress (info: ApplicantInfo|OrganizationInfo) { return this.addressFormatter.format({ address1: info.address, address2: info.address2, city: info.city, postalCode: info.postalCode, countryCode: info.country, stateProvRegCode: info.state, stateProvRegName: info.state }); } setHiddenFilters () { const isPending = new TopLevelFilter( 'equals', 'statusId', +PaymentStatus.Pending, '' ); const isFulfilled = new TopLevelFilter( 'equals', 'statusId', +PaymentStatus.Fulfilled, '' ); if (this.inKindStatus) { this.hiddenFilters = [ this.inKindStatus === PaymentStatus.Pending ? isPending : isFulfilled ]; } else { const typeFilter = new TopLevelFilter( 'notEqual', 'FundingSourceType', FundingSourceTypes.UNITS, '' ); const processor = new TopLevelFilter( 'equals', 'paymentProcessingType', this.isYourCause ? ProcessingTypes.YourCause : ProcessingTypes.Client, '' ); const programFilter = new TopLevelFilter( 'checkboxDropdown', 'programId', this.programIds, undefined, { selectOptions: this.programOptions } ); if (this.isAvailable) { let onHoldExcluded; if (this.isIncluded) { onHoldExcluded = [ new TopLevelFilter( 'equals', 'onHold', false, '' ), new TopLevelFilter( 'equals', 'missingPayeeInfo', false, '' ), new TopLevelFilter( 'equals', 'addressRequestInvalid', false, '' ), new TopLevelFilter( 'equals', 'notEligibleForGiving', false, '' ) ]; } else { onHoldExcluded = [ new TopLevelFilter( 'equals', '', true, '', undefined, undefined, [ { column: 'onHold', filterType: 'eq', customValue: true }, { column: 'missingPayeeInfo', filterType: 'eq', customValue: true }, { column: 'addressRequestInvalid', filterType: 'eq', customValue: true }, { column: 'notEligibleForGiving', filterType: 'eq', customValue: true } ] ) ]; } this.hiddenFilters = [ ...onHoldExcluded, processor, isPending, typeFilter, this.programIds?.length > 0 ? programFilter : null ].filter((filter) => !!filter); } else { if (!this.isIncluded) { this.hiddenFilters = [ isPending, typeFilter, processor, this.programIds?.length > 0 ? programFilter : null ].filter((filter) => !!filter); } } } } async paymentDetails (payment: PaymentForProcess) { const response = await this.modalFactory.open( PaymentDetailsModalComponent, { payment, batchStatus: this.batchStatus } ); if (response) { this.resetTableAndFilters(); } } async removePaymentFromBatch (payment: PaymentForProcess){ this.spinnerService.startSpinner(); const isEligible = await this.paymentProcessingService.handlePaymentIsEligibleForRecall(payment.paymentId); this.spinnerService.stopSpinner(); const response = await this.modalFactory.open(PaymentRecallModalComponent, { payment, isEligible }); if (response) { this.spinnerService.startSpinner(); await this.paymentProcessingService.handleRecallPayment({ paymentId: payment.paymentId, reasonForRecall: response.comment }); this.resetTableAndFilters(); this.spinnerService.stopSpinner(); } } async includeOrExcludePayment (row: PaymentForProcess, include = true) { const proceed = await this.modalFactory.open( ConfirmationModalComponent, { confirmText: this.i18n.translate( include ? 'MANAGE:textAreYouSureIncludeInBatch' : 'MANAGE:textAreYouSureExcludeInBatch', {}, include ? 'Are you sure you want to include this payment in the batch?' : 'Are you sure you want to exclude this payment from the batch?' ), modalHeader: `${this.i18n.translate( include ? 'MANAGE:textIncludePayment' : 'MANAGE:textExcludePayment', {}, include ? 'Include Payment' : 'Exclude Payment' )}`, confirmButtonText: this.i18n.translate( include ? 'GLOBAL:textInclude' : 'GLOBAL:textExclude', {}, include ? 'Include' : 'Exclude' ) } ); if (proceed) { this.spinnerService.startSpinner(); try { const type = include ? 'addPaymentsToBatch' : 'removePaymentsFromBatch'; await this.paymentProcessingResources[type]( [row.paymentId], this.batchId ); this.notifier.success(this.i18n.translate( include ? 'MANAGE:textSuccessfullyAddedPaymentToBatch' : 'MANAGE:textSuccessfullyRemovedPaymentToBatch', {}, include ? 'Successfully added payment to batch' : 'Successfully removed payment from batch' )); this.resetCurrentRepo(); await this.paymentProcessingService.resetPaymentStats(); this.onBatchChanged.emit(); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( include ? 'MANAGE:textErrorIncludingPayment' : 'MANAGE:textErrorExcludingPayment', {}, include ? 'There was an error adding the payment to the batch' : 'There was an error removing the payment from the batch' )); } this.spinnerService.stopSpinner(); } } async expedite (row: PaymentForProcess) { const response = await this.modalFactory.open( ExpeditePaymentModalComponent, { paymentId: row.paymentId } ); if (response) { this.spinnerService.startSpinner(); const data: ExpeditePaymentForApi = { batchName: response.name, batchNotes: response.notes, paymentId: row.paymentId, batchProcessingTypeId: this.isYourCause ? ProcessingTypes.YourCause : ProcessingTypes.Client }; const batchId = await this.paymentProcessingService.handleExpediteModal( data ); if (batchId) { this.router.navigate([ `/management/payment-processing/manage-batch/${batchId}/included` ]); } this.spinnerService.stopSpinner(); } } async hold (row: PaymentForProcess) { const response = await this.modalFactory.open( HoldPaymentModalComponent, { paymentId: row.paymentId, toHold: !row.onHold, isAllPayments: false } ); if (response) { this.spinnerService.startSpinner(); const toHold = !row.onHold; await this.paymentProcessingService.handleHoldModal( row.paymentId, toHold, response.notes ); await this.updatePaymentStats(); this.resetCurrentRepo(); this.spinnerService.stopSpinner(); } } async updatePaymentStats () { await this.paymentProcessingService.getProcessingStats(); if ( !this.isIncluded && this.paymentProcessingService.get('currentIncludedOptions') ) { await this.paymentProcessingService.getAndSetPaginatedStats( this.paymentProcessingService.get('currentIncludedOptions'), true ); } } async attention (row: PaymentForProcess) { const attentionText = this.i18n.translate( 'FORMS:textAttention', {}, 'Attention' ); const paymentIDText = this.i18n.translate( 'MANAGE:hdrPaymentIdDynamic', { paymentId: row.paymentId }, 'Payment ID: __paymentId__' ); const attentionDescriptionText = this.i18n.translate( 'FORMS:textAttentionDescription', {}, 'Attention allows payments to go to a specific person or department at the above address' ); const modalResponse = await this.modalFactory.open(SimpleUpdateModalComponent, { modalHeader: attentionText, modalSubHeader: paymentIDText, existingValue: row.careOf, inputLabel: attentionText, inputDescription: attentionDescriptionText }); if (modalResponse) { this.spinnerService.startSpinner(); await this.awardResources.adjustPayment( row.applicationId, row.awardId, row.paymentId, { scheduledDate: row.scheduledDate, value: row.totalAmount, notes: row.notes, paymentDesignation: TextFriendlySpecialCharCleaner(row.paymentDesignation), currencyRequested: row.currencyRequested, valueEquivalent: row.totalAmountEquivalent, currencyExchangeRate: row.currencyExchangeRate, differentThanConversion: row.differentThanConversion, inKindPaymentItemsRequested: row.inKindItems, careOf: modalResponse.payload, budgetId: row.budgetId, fundingSourceId: row.fundingSourceId } ); this.resetCurrentRepo(); await this.updatePaymentStats(); this.spinnerService.stopSpinner(); } } async designation (row: PaymentForProcess) { const response = await this.modalFactory.open( DesignationModalComponent, { paymentId: row.paymentId, applicationId: row.applicationId, paymentDesignation: TextFriendlySpecialCharCleaner(row.paymentDesignation) } ); if (response) { this.spinnerService.startSpinner(); await this.awardResources.adjustPayment( row.applicationId, row.awardId, row.paymentId, { scheduledDate: row.scheduledDate, value: row.totalAmount, notes: row.notes, paymentDesignation: TextFriendlySpecialCharCleaner(response.paymentDesignation), currencyRequested: row.currencyRequested, valueEquivalent: row.totalAmountEquivalent, currencyExchangeRate: row.currencyExchangeRate, differentThanConversion: row.differentThanConversion, inKindPaymentItemsRequested: row.inKindItems, careOf: row.careOf, budgetId: row.budgetId, fundingSourceId: row.fundingSourceId } ); this.resetCurrentRepo(); await this.updatePaymentStats(); this.spinnerService.stopSpinner(); } } async specialHandling (row: PaymentForProcess) { this.spinnerService.startSpinner(); const { defaultAddress } = await this.specialHandlingService.getDefaultSpecialHandling( row.organizationInfo?.organizationId, row.organizationInfo?.nonprofitGuid, row.organizationInfo?.name, row.applicantInfo?.applicantId, row.applicantInfo?.firstName, row.applicantInfo?.lastName, row.applicantInfo?.email, row.applicantInfo?.phoneNumber ); this.spinnerService.stopSpinner(); const response = await this.modalFactory.open( SpecialHandlingModalComponent, { paymentId: row.paymentId, payeeOverride: row.payeeOverrideInfo, name: defaultAddress.name || row.organizationInfo?.name || row.applicantInfo?.fullName, defaultAddress, applicationId: row.applicationId, isClientProcessed: row.paymentProcessingType === ProcessingTypes.Client } ); if (response) { this.spinnerService.startSpinner(); await this.paymentProcessingService.handleSpecialHandlingModal( response ); this.resetCurrentRepo(); await this.updatePaymentStats(); this.spinnerService.stopSpinner(); } } async updatePayments (payments: PaymentForProcess[]) { const response = await this.modalFactory.open( UpdatePaymentsModalComponent, { payments, isInKind: this.isInKind } ); if (response) { this.spinnerService.startSpinner(); await this.paymentProcessingService.handleUpdatePayments({ ...response, status: response.status || undefined, paymentIds: payments.map((pay) => pay.paymentId) }); this.resetCurrentRepo(); if (this.isInKind) { await this.paymentProcessingService.resetInKindPaymentStats(); } this.spinnerService.stopSpinner(); } } async notifyPayeesModal (payments: PaymentForProcess[]) { const response = await this.modalFactory.open( NotifyPayeesModalComponent, { payments } ); if (response) { this.spinnerService.startSpinner(); await this.paymentProcessingService.handleNotifyPayees( response, payments.map((pay) => pay.paymentId) ); this.spinnerService.stopSpinner(); } } }