diff --git a/migrations/app/migrations_manifest.txt b/migrations/app/migrations_manifest.txt index ea07d31b0f5..ec8404f9561 100644 --- a/migrations/app/migrations_manifest.txt +++ b/migrations/app/migrations_manifest.txt @@ -1075,6 +1075,7 @@ 20250103130619_revert_data_change_for_gbloc_for_ak.up.sql 20250103142533_update_postal_codes_and_gblocs_for_ak.up.sql 20250103180420_update_pricing_proc_to_use_local_price_variable.up.sql +20250109194140_create_audit_history_table_for_payment_service_items.up.sql 20250110001339_update_nts_release_enum_name.up.sql 20250110153428_add_shipment_address_updates_to_move_history.up.sql 20250113152050_rename_ubp.up.sql diff --git a/migrations/app/schema/20250109194140_create_audit_history_table_for_payment_service_items.up.sql b/migrations/app/schema/20250109194140_create_audit_history_table_for_payment_service_items.up.sql new file mode 100644 index 00000000000..8318441ef8d --- /dev/null +++ b/migrations/app/schema/20250109194140_create_audit_history_table_for_payment_service_items.up.sql @@ -0,0 +1 @@ +SELECT add_audit_history_table(target_table := 'payment_service_items', audit_rows := BOOLEAN 't', audit_query_text := BOOLEAN 't', ignored_cols := ARRAY['created_at', 'updated_at', 'denied_at', 'requested_at', 'sent_to_gex_at', 'approved_at']); \ No newline at end of file diff --git a/pkg/assets/sql_scripts/move_history_fetcher.sql b/pkg/assets/sql_scripts/move_history_fetcher.sql index 75f961fa881..fc293e20377 100644 --- a/pkg/assets/sql_scripts/move_history_fetcher.sql +++ b/pkg/assets/sql_scripts/move_history_fetcher.sql @@ -216,7 +216,8 @@ WITH move AS ( 'shipment_id', move_shipments.id::TEXT, 'shipment_id_abbr', move_shipments.shipment_id_abbr, 'shipment_type', move_shipments.shipment_type, - 'shipment_locator', move_shipments.shipment_locator + 'shipment_locator', move_shipments.shipment_locator, + 'rejection_reason', payment_service_items.rejection_reason ) )::TEXT AS context, payment_requests.id AS id, @@ -243,6 +244,42 @@ WITH move AS ( JOIN move_payment_requests ON move_payment_requests.id = audit_history.object_id WHERE audit_history.table_name = 'payment_requests' ), + move_payment_service_items AS ( + SELECT + jsonb_agg(jsonb_build_object( + 'name', re_services.name, + 'price', payment_service_items.price_cents::TEXT, + 'status', payment_service_items.status, + 'rejection_reason', payment_service_items.rejection_reason, + 'paid_at', payment_service_items.paid_at, + 'shipment_id', move_shipments.id::TEXT, + 'shipment_id_abbr', move_shipments.shipment_id_abbr, + 'shipment_type', move_shipments.shipment_type, + 'shipment_locator', move_shipments.shipment_locator + ) + )::TEXT AS context, + payment_service_items.id AS id + FROM + payment_requests + JOIN payment_service_items ON payment_service_items.payment_request_id = payment_requests.id + JOIN move_service_items ON move_service_items.id = payment_service_items.mto_service_item_id + LEFT JOIN move_shipments ON move_shipments.id = move_service_items.mto_shipment_id + JOIN re_services ON move_service_items.re_service_id = re_services.id + WHERE + payment_requests.move_id = (SELECT move.id FROM move) + GROUP BY + payment_service_items.id + ), + payment_service_items_logs AS ( + SELECT DISTINCT + audit_history.*, + context AS context, + NULL AS context_id + FROM + audit_history + JOIN move_payment_service_items ON move_payment_service_items.id = audit_history.object_id + WHERE audit_history.table_name = 'payment_service_items' + ), move_proof_of_service_docs AS ( SELECT proof_of_service_docs.*, @@ -721,6 +758,11 @@ WITH move AS ( FROM payment_requests_logs UNION + SELECT + * + FROM + payment_service_items_logs + UNION SELECT * FROM diff --git a/src/constants/MoveHistory/Database/Tables.js b/src/constants/MoveHistory/Database/Tables.js index f860416dda7..921febc1464 100644 --- a/src/constants/MoveHistory/Database/Tables.js +++ b/src/constants/MoveHistory/Database/Tables.js @@ -19,5 +19,6 @@ export default { moving_expenses: 'moving_expenses', progear_weight_tickets: 'progear_weight_tickets', gsr_appeals: 'gsr_appeals', + payment_service_items: 'payment_service_items', shipment_address_updates: 'shipment_address_updates', }; diff --git a/src/constants/MoveHistory/EventTemplates/UpdatePaymentServiceItem/UpdatePaymentServiceItemStatus.jsx b/src/constants/MoveHistory/EventTemplates/UpdatePaymentServiceItem/UpdatePaymentServiceItemStatus.jsx new file mode 100644 index 00000000000..8733ec0bc30 --- /dev/null +++ b/src/constants/MoveHistory/EventTemplates/UpdatePaymentServiceItem/UpdatePaymentServiceItemStatus.jsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import o from 'constants/MoveHistory/UIDisplay/Operations'; +import a from 'constants/MoveHistory/Database/Actions'; +import t from 'constants/MoveHistory/Database/Tables'; +import { getMtoShipmentLabel } from 'utils/formatMtoShipment'; +import LabeledDetails from 'pages/Office/MoveHistory/LabeledDetails'; +import { PAYMENT_SERVICE_ITEM_STATUS } from 'shared/constants'; + +const formatChangedValues = (historyRecord) => { + const newChangedValues = { + ...historyRecord.changedValues, + ...getMtoShipmentLabel(historyRecord), + }; + + // Removed unneeded values to avoid clutter in audit log + if (newChangedValues.status === 'APPROVED' || newChangedValues.status === 'REQUESTED') { + delete newChangedValues.rejection_reason; + } + delete newChangedValues.status; + const newHistoryRecord = { ...historyRecord }; + delete newHistoryRecord.changedValues.status; + return { ...newHistoryRecord, changedValues: newChangedValues }; +}; + +export default { + action: a.UPDATE, + eventName: o.updatePaymentServiceItemStatus, + tableName: t.payment_service_items, + getEventNameDisplay: (historyRecord) => { + let actionPrefix = ''; + + /** + * IF there is a rejection_reason present in the changedValues, then either the reason was updated (in which case the status will be undefined) + * OR it was just rejected, wither way we want the rejected prefix, second || condition is a "just in case" check, not sure if there's a state + * where status would be updated but not rejection_reason + */ + if ( + ('rejection_reason' in historyRecord.changedValues && + historyRecord.changedValues.rejection_reason !== null && + historyRecord.changedValues.status !== 'APPROVED' && + historyRecord.changedValues.status !== 'REQUESTED') || + historyRecord.changedValues.status === PAYMENT_SERVICE_ITEM_STATUS.DENIED + ) { + actionPrefix = 'Rejected'; + } else if (historyRecord.changedValues.status === PAYMENT_SERVICE_ITEM_STATUS.APPROVED) { + actionPrefix = 'Approved'; + } else { + actionPrefix = 'Updated'; + } + return
{actionPrefix} Payment Service Item
; + }, + getDetails: (historyRecord) => { + return ; + }, +}; diff --git a/src/constants/MoveHistory/EventTemplates/UpdatePaymentServiceItem/UpdatePaymentServiceItemStatus.test.jsx b/src/constants/MoveHistory/EventTemplates/UpdatePaymentServiceItem/UpdatePaymentServiceItemStatus.test.jsx new file mode 100644 index 00000000000..1d0c3216a91 --- /dev/null +++ b/src/constants/MoveHistory/EventTemplates/UpdatePaymentServiceItem/UpdatePaymentServiceItemStatus.test.jsx @@ -0,0 +1,88 @@ +import { render, screen } from '@testing-library/react'; + +import getTemplate from 'constants/MoveHistory/TemplateManager'; +import o from 'constants/MoveHistory/UIDisplay/Operations'; +import a from 'constants/MoveHistory/Database/Actions'; +import t from 'constants/MoveHistory/Database/Tables'; + +describe('When approving/rejecting a payment service item', () => { + const rejectPaymentServiceItemRecord = { + action: a.UPDATE, + actionTstampClk: '2025-01-10T19:44:31.255Z', + actionTstampStm: '2025-01-10T19:44:31.253Z', + actionTstampTx: '2025-01-10T19:44:31.220Z', + context: [ + { + shipment_type: 'PPM', + shipment_locator: 'RQ38D4-01', + shipment_id_abbr: 'f10be', + }, + ], + changedValues: { + rejection_reason: 'Some reason', + status: 'DENIED', + }, + eventName: o.updatePaymentServiceItemStatus, + tableName: t.payment_service_items, + id: '2419f1db-3f8b-4823-974f-9aa4edb753da', + objectId: 'eee30fb1-dc66-4821-a17c-2ecf431ceb9d', + oldValues: { + id: 'eee30fb1-dc66-4821-a17c-2ecf431ceb9d', + ppm_shipment_id: '86329c14-564b-4580-94b9-8a2e80bccefc', + reason: null, + status: null, + }, + }; + + const approvePaymentServiceItemRecord = { ...rejectPaymentServiceItemRecord }; + approvePaymentServiceItemRecord.changedValues = { + status: 'APPROVED', + }; + + it('displays an approved payment service item', () => { + const template = getTemplate(approvePaymentServiceItemRecord); + + render(template.getEventNameDisplay(approvePaymentServiceItemRecord)); + expect(screen.getByText('Approved Payment Service Item')).toBeInTheDocument(); + + render(template.getDetails(approvePaymentServiceItemRecord)); + expect(screen.getByText('PPM shipment #RQ38D4-01')).toBeInTheDocument(); + }); + + it('displays an updated payment service item', () => { + const updatedServiceItemRecord = { ...approvePaymentServiceItemRecord }; + delete updatedServiceItemRecord.changedValues.status; + delete updatedServiceItemRecord.changedValues.rejection_reason; + const template = getTemplate(updatedServiceItemRecord); + + render(template.getEventNameDisplay(updatedServiceItemRecord)); + expect(screen.getByText('Updated Payment Service Item')).toBeInTheDocument(); + + render(template.getDetails(updatedServiceItemRecord)); + expect(screen.getByText('PPM shipment #RQ38D4-01')).toBeInTheDocument(); + }); + + it('displays a rejected payment service item and the rejection reason', () => { + const template = getTemplate(rejectPaymentServiceItemRecord); + + render(template.getEventNameDisplay(rejectPaymentServiceItemRecord)); + expect(screen.getByText('Rejected Payment Service Item')).toBeInTheDocument(); + + render(template.getDetails(rejectPaymentServiceItemRecord)); + expect(screen.getByText('Reason')).toBeInTheDocument(); + expect(screen.getByText(': Some reason')).toBeInTheDocument(); + }); + + it('displays a cleared payment service item with no unneeded information', () => { + const clearedServiceItem = rejectPaymentServiceItemRecord; + clearedServiceItem.changedValues.status = 'REQUESTED'; + const template = getTemplate(clearedServiceItem); + + render(template.getEventNameDisplay(clearedServiceItem)); + expect(screen.getByText('Updated Payment Service Item')).toBeInTheDocument(); + + render(template.getDetails(clearedServiceItem)); + expect(screen.queryByText('Reason')).not.toBeInTheDocument(); + expect(screen.queryByText(': Some reason')).not.toBeInTheDocument(); + }); +}); diff --git a/src/constants/MoveHistory/EventTemplates/index.js b/src/constants/MoveHistory/EventTemplates/index.js index 9036e31baaf..6f0e8209f7e 100644 --- a/src/constants/MoveHistory/EventTemplates/index.js +++ b/src/constants/MoveHistory/EventTemplates/index.js @@ -115,3 +115,4 @@ export { default as updateAssignedOfficeUser } from './UpdateAssignedOfficeUser/ export { default as deleteAssignedOfficeUser } from './UpdateAssignedOfficeUser/DeleteAssignedOfficeUser'; export { default as UpdatePaymentRequestStatusMoves } from './UpdatePaymentRequestStatus/UpdatePaymentRequestStatusMoves'; export { default as reviewShipmentAddressUpdate } from './ReviewShipmentAddressUpdate/reviewShipmentAddressUpdate'; +export { default as updatePaymentServiceItemStatus } from './UpdatePaymentServiceItem/UpdatePaymentServiceItemStatus'; diff --git a/src/constants/MoveHistory/UIDisplay/Operations.js b/src/constants/MoveHistory/UIDisplay/Operations.js index b3a68442f79..43a8327210b 100644 --- a/src/constants/MoveHistory/UIDisplay/Operations.js +++ b/src/constants/MoveHistory/UIDisplay/Operations.js @@ -35,6 +35,7 @@ export default { updateOrder: 'updateOrder', // ghc.yaml updateOrders: 'updateOrders', // internal.yaml updatePaymentRequestStatus: 'updatePaymentRequestStatus', + updatePaymentServiceItemStatus: 'updatePaymentServiceItemStatus', updateReweigh: 'updateReweigh', updateServiceItemStatus: 'updateMTOServiceItemStatus', updateServiceItemSitEntryDate: 'updateServiceItemSitEntryDate', // ghc.yaml diff --git a/src/pages/Office/MoveHistory/PaymentDetails.jsx b/src/pages/Office/MoveHistory/PaymentDetails.jsx index 65af4dbe90c..f1b38320d23 100644 --- a/src/pages/Office/MoveHistory/PaymentDetails.jsx +++ b/src/pages/Office/MoveHistory/PaymentDetails.jsx @@ -24,6 +24,13 @@ const filterContextStatus = (context, statusToFilter) => {
{value.name}
{price.toFixed(2)}
+
+ {value.status === PAYMENT_SERVICE_ITEM_STATUS.DENIED ? ( +
+ Rejection Reason: + {value?.rejection_reason} +
+ ) : null}
, ); } diff --git a/src/pages/Office/MoveHistory/PaymentDetails.module.scss b/src/pages/Office/MoveHistory/PaymentDetails.module.scss index ba62a69fd09..8e347f56909 100644 --- a/src/pages/Office/MoveHistory/PaymentDetails.module.scss +++ b/src/pages/Office/MoveHistory/PaymentDetails.module.scss @@ -13,6 +13,7 @@ font-weight: 500; display: flex; justify-content: space-between; + flex-wrap: wrap; } .statusRow { display: flex; @@ -24,4 +25,11 @@ .rejectTimes { color: $error; } + .break { + flex-basis: 100%; + height: 0; + } + .rejectionReason { + text-indent: 1rem; + } } diff --git a/src/pages/Office/MoveHistory/PaymentDetails.test.jsx b/src/pages/Office/MoveHistory/PaymentDetails.test.jsx index fca870071c2..f9f38895d70 100644 --- a/src/pages/Office/MoveHistory/PaymentDetails.test.jsx +++ b/src/pages/Office/MoveHistory/PaymentDetails.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; import PaymentDetails from './PaymentDetails'; @@ -47,4 +47,26 @@ describe('PaymentDetails', () => { expect(screen.getByText(156.78, { exact: false })).toBeInTheDocument(); }); + + describe('rejected service items', () => { + const context = [ + { + name: 'Domestic uncrating', + price: '5555', + status: 'DENIED', + rejection_reason: 'some reason', + }, + ]; + it('renders a rejected service item and its rejection reason', async () => { + render(); + + expect(screen.getByText('Domestic uncrating')).toBeInTheDocument(); + + expect(screen.getByText('Rejection Reason:')).toBeInTheDocument(); + await act(() => { + screen.getByText('Rejection Reason:').click(); + }); + expect(screen.getByText('some reason')).toBeVisible(); + }); + }); });