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();
+ });
+ });
});