diff --git a/src/applications/combined-debt-portal/combined/actions/copays.js b/src/applications/combined-debt-portal/combined/actions/copays.js index afa49f580d1f..e45ba6413e7e 100644 --- a/src/applications/combined-debt-portal/combined/actions/copays.js +++ b/src/applications/combined-debt-portal/combined/actions/copays.js @@ -10,6 +10,12 @@ export const MCP_STATEMENTS_FETCH_FAILURE = 'MCP_STATEMENTS_FETCH_FAILURE'; export const MCP_DETAIL_FETCH_SUCCESS = 'MCP_DETAIL_FETCH_SUCCESS'; export const MCP_DETAIL_FETCH_FAILURE = 'MCP_DETAIL_FETCH_FAILURE'; export const MCP_DETAIL_FETCH_INIT = 'MCP_DETAIL_FETCH_INIT'; +export const MCP_MONTHLY_STATEMENT_FETCH_INIT = + 'MCP_MONTHLY_STATEMENT_FETCH_INIT'; +export const MCP_MONTHLY_STATEMENT_FETCH_SUCCESS = + 'MCP_MONTHLY_STATEMENT_FETCH_SUCCESS'; +export const MCP_MONTHLY_STATEMENT_FETCH_FAILURE = + 'MCP_MONTHLY_STATEMENT_FETCH_FAILURE'; export const mcpStatementsFetchInit = () => ({ type: MCP_STATEMENTS_FETCH_INIT, @@ -142,3 +148,31 @@ export const getCopayDetailStatement = copayId => async ( }); }); }; + +export const getMonthlyStatementCopay = copayId => async ( + dispatch, + getState, +) => { + dispatch({ type: MCP_MONTHLY_STATEMENT_FETCH_INIT }); + + const dataUrl = `${environment.API_URL}/v1/medical_copays/${copayId}`; + + return apiRequest(dataUrl) + .then(response => { + const shouldUseLighthouseCopays = + showVHAPaymentHistory(getState()) && !response.isCerner; + + return dispatch({ + type: MCP_MONTHLY_STATEMENT_FETCH_SUCCESS, + response, + shouldUseLighthouseCopays, + }); + }) + .catch(err => { + const error = err?.errors?.[0] ?? err; + return dispatch({ + type: MCP_MONTHLY_STATEMENT_FETCH_FAILURE, + error, + }); + }); +}; diff --git a/src/applications/combined-debt-portal/combined/reducers/index.js b/src/applications/combined-debt-portal/combined/reducers/index.js index e163b25bf9d4..eb395407ee00 100644 --- a/src/applications/combined-debt-portal/combined/reducers/index.js +++ b/src/applications/combined-debt-portal/combined/reducers/index.js @@ -17,6 +17,9 @@ import { MCP_STATEMENTS_FETCH_SUCCESS, MCP_STATEMENTS_FETCH_FAILURE, MCP_DETAIL_FETCH_INIT, + MCP_MONTHLY_STATEMENT_FETCH_INIT, + MCP_MONTHLY_STATEMENT_FETCH_SUCCESS, + MCP_MONTHLY_STATEMENT_FETCH_FAILURE, } from '../actions/copays'; const debtInitialState = { @@ -39,6 +42,9 @@ const mcpInitialState = { selectedStatement: null, shouldUseLighthouseCopays: null, isCopayDetailLoading: false, + monthlyStatementCopay: null, + isMonthlyStatementLoading: false, + monthlyStatementError: null, }; export const medicalCopaysReducer = (state = mcpInitialState, action) => { @@ -85,6 +91,27 @@ export const medicalCopaysReducer = (state = mcpInitialState, action) => { pending: false, error: action.error, }; + case MCP_MONTHLY_STATEMENT_FETCH_INIT: + return { + ...state, + monthlyStatementCopay: null, + isMonthlyStatementLoading: true, + monthlyStatementError: null, + }; + case MCP_MONTHLY_STATEMENT_FETCH_SUCCESS: { + return { + ...state, + monthlyStatementCopay: action.response.data, + isMonthlyStatementLoading: false, + monthlyStatementError: null, + }; + } + case MCP_MONTHLY_STATEMENT_FETCH_FAILURE: + return { + ...state, + isMonthlyStatementLoading: false, + monthlyStatementError: action.error, + }; default: return state; } diff --git a/src/applications/combined-debt-portal/combined/routes.jsx b/src/applications/combined-debt-portal/combined/routes.jsx index 3c6b66c5170e..7b1dd8cb73fa 100644 --- a/src/applications/combined-debt-portal/combined/routes.jsx +++ b/src/applications/combined-debt-portal/combined/routes.jsx @@ -5,7 +5,7 @@ import OverviewPage from './containers/OverviewPage'; import CombinedPortalApp from './containers/CombinedPortalApp'; import CombinedStatements from './containers/CombinedStatements'; import Details from '../medical-copays/containers/Details'; -import HTMLStatementPage from '../medical-copays/containers/HTMLStatementPage'; +import MonthlyStatementPage from '../medical-copays/containers/MonthlyStatementPage'; import MCPOverview from '../medical-copays/containers/SummaryPage'; import DebtDetails from '../debt-letters/containers/DebtDetails'; import DebtLettersDownload from '../debt-letters/containers/DebtLettersDownload'; @@ -28,8 +28,8 @@ const Routes = () => ( diff --git a/src/applications/combined-debt-portal/combined/tests/unit/actionsCopays.unit.spec.jsx b/src/applications/combined-debt-portal/combined/tests/unit/actionsCopays.unit.spec.jsx index e4f726d72c49..277f7df267f6 100644 --- a/src/applications/combined-debt-portal/combined/tests/unit/actionsCopays.unit.spec.jsx +++ b/src/applications/combined-debt-portal/combined/tests/unit/actionsCopays.unit.spec.jsx @@ -11,10 +11,14 @@ import { MCP_DETAIL_FETCH_INIT, MCP_DETAIL_FETCH_SUCCESS, MCP_DETAIL_FETCH_FAILURE, + MCP_MONTHLY_STATEMENT_FETCH_INIT, + MCP_MONTHLY_STATEMENT_FETCH_SUCCESS, + MCP_MONTHLY_STATEMENT_FETCH_FAILURE, mcpStatementsFetchInit, getAllCopayStatements, getCopaySummaryStatements, getCopayDetailStatement, + getMonthlyStatementCopay, } from '../../actions/copays'; describe('copays actions', () => { @@ -442,4 +446,53 @@ describe('copays actions', () => { }); }); }); + + describe('getMonthlyStatementCopay', () => { + it('should dispatch monthly statement INIT and SUCCESS with shouldUseLighthouseCopays', async () => { + const fakeResponse = { + data: { id: 'm-copay-1' }, + isCerner: false, + }; + apiRequestStub.resolves(fakeResponse); + showVHAPaymentHistoryStub.returns(true); + + await getMonthlyStatementCopay('m-copay-1')(dispatch, () => ({})); + + expect(dispatch.firstCall.args[0]).to.deep.equal({ + type: MCP_MONTHLY_STATEMENT_FETCH_INIT, + }); + expect(dispatch.secondCall.args[0]).to.deep.equal({ + type: MCP_MONTHLY_STATEMENT_FETCH_SUCCESS, + response: fakeResponse, + shouldUseLighthouseCopays: true, + }); + }); + + it('should set shouldUseLighthouseCopays false when Cerner', async () => { + const fakeResponse = { + data: { id: 'm-copay-1' }, + isCerner: true, + }; + apiRequestStub.resolves(fakeResponse); + showVHAPaymentHistoryStub.returns(true); + + await getMonthlyStatementCopay('m-copay-1')(dispatch, () => ({})); + + expect(dispatch.secondCall.args[0].shouldUseLighthouseCopays).to.be.false; + }); + + it('should dispatch MONTHLY_STATEMENT_FETCH_FAILURE on API error', async () => { + apiRequestStub.rejects({ errors: [errors.notFoundError] }); + + await getMonthlyStatementCopay('missing')(dispatch, () => ({})); + + expect(dispatch.firstCall.args[0]).to.deep.equal({ + type: MCP_MONTHLY_STATEMENT_FETCH_INIT, + }); + expect(dispatch.secondCall.args[0]).to.deep.equal({ + type: MCP_MONTHLY_STATEMENT_FETCH_FAILURE, + error: errors.notFoundError, + }); + }); + }); }); diff --git a/src/applications/combined-debt-portal/combined/tests/unit/combinedHelpers.unit.spec.jsx b/src/applications/combined-debt-portal/combined/tests/unit/combinedHelpers.unit.spec.jsx index 06190955c66c..2d180d304aa7 100644 --- a/src/applications/combined-debt-portal/combined/tests/unit/combinedHelpers.unit.spec.jsx +++ b/src/applications/combined-debt-portal/combined/tests/unit/combinedHelpers.unit.spec.jsx @@ -386,6 +386,10 @@ describe('Helper Functions', () => { it('should handle ISO date strings', () => { expect(formatDate('2023-05-15')).to.equal('May 15, 2023'); }); + + it('should handle VBS compact pSStatementDate (MMddyyyy)', () => { + expect(formatDate('12112025')).to.equal('December 11, 2025'); + }); }); describe('currency edge cases', () => { diff --git a/src/applications/combined-debt-portal/combined/tests/unit/selectors.unit.spec.jsx b/src/applications/combined-debt-portal/combined/tests/unit/selectors.unit.spec.jsx index 9f4d4882e814..913f64a7cf6e 100644 --- a/src/applications/combined-debt-portal/combined/tests/unit/selectors.unit.spec.jsx +++ b/src/applications/combined-debt-portal/combined/tests/unit/selectors.unit.spec.jsx @@ -6,14 +6,16 @@ import { selectLighthouseStatementGroups, selectLighthousePreviousStatements, selectCurrentStatementMcpState, + selectMonthlyStatement, } from '../../utils/selectors'; import { vbsCompositeId } from '../../utils/vbsCopayStatements'; +import { firstDayOfFollowingMonthFromCopayDate } from '../../utils/helpers'; /** * Mock shapes align with vets-api medical copays: * - V0 GET /v0/medical_copays `data[]`: string `id`, `pSFacilityNum`, `pSStatementDateOutput`, … * - V1 GET /v1/medical_copays/{id} (non-Cerner): `selectedStatement` with `attributes.associatedStatements[]` - * (`id`, `date`, `compositeId` per schema; invoice display uses attributes.invoiceDate ?? invoiceDate ?? date). + * (`id`, `date`, `compositeId`, `attributes.invoiceDate` per schema; list date from `firstDayOfFollowingMonthFromCopayDate`). */ const FACILITY = '648'; @@ -50,18 +52,42 @@ const mcpStateWithDetail = selectedStatement => ({ }); describe('combined utils/selectors', () => { + describe('firstDayOfFollowingMonthFromCopayDate', () => { + it('returns MMMM d, yyyy for the first day of the month after the copay month', () => { + expect(firstDayOfFollowingMonthFromCopayDate('02/28/2024')).to.equal( + 'March 1, 2024', + ); + expect(firstDayOfFollowingMonthFromCopayDate('2025-04-30')).to.equal( + 'May 1, 2025', + ); + }); + + it('returns empty string for empty or invalid input', () => { + expect(firstDayOfFollowingMonthFromCopayDate('')).to.equal(''); + expect(firstDayOfFollowingMonthFromCopayDate(null)).to.equal(''); + expect(firstDayOfFollowingMonthFromCopayDate(undefined)).to.equal(''); + }); + + it('accepts an optional date-fns output format', () => { + expect( + firstDayOfFollowingMonthFromCopayDate('02/28/2024', 'MM/dd/yyyy'), + ).to.equal('03/01/2024'); + }); + }); + describe('groupVbsCopaysByStatements', () => { - it('returns id/pSStatementDateOutput for every copay in grouped output (all rows, including multiple per month)', () => { + it('returns one entry per group with statementId = compositeId and following-month label date', () => { const laterInFeb = '6fa85f64-5717-4562-b3fc-2c963f66afa9'; + const febComposite = vbsCompositeId(FACILITY, 2, 2024); const grouped = [ { - compositeId: vbsCompositeId(FACILITY, 2, 2024), + compositeId: febComposite, copays: [ v0CopayRow(PRIOR_FEB_ID, '02/28/2024', { - compositeId: vbsCompositeId(FACILITY, 2, 2024), + compositeId: febComposite, }), v0CopayRow(laterInFeb, '02/05/2024', { - compositeId: vbsCompositeId(FACILITY, 2, 2024), + compositeId: febComposite, }), ], }, @@ -73,16 +99,12 @@ describe('combined utils/selectors', () => { expect(groupVbsCopaysByStatements(grouped)).to.deep.equal([ { - id: PRIOR_FEB_ID, - pSStatementDateOutput: '02/28/2024', - }, - { - id: laterInFeb, - pSStatementDateOutput: '02/05/2024', + statementId: febComposite, + date: 'March 1, 2024', }, { - id: PRIOR_JAN_ID, - pSStatementDateOutput: '01/10/2024', + statementId: vbsCompositeId(FACILITY, 1, 2024), + date: 'February 1, 2024', }, ]); }); @@ -173,7 +195,7 @@ describe('combined utils/selectors', () => { }); describe('selectLighthousePreviousStatements', () => { - it('maps associated statements to id and invoiceDate (attributes.invoiceDate, then invoiceDate, then date)', () => { + it('maps one entry per compositeId; date is firstDayOfFollowingMonthFromCopayDate(lead.date)', () => { const state = mcpStateWithDetail({ id: '675-K3FD983', type: 'medicalCopayDetails', @@ -182,36 +204,30 @@ describe('combined utils/selectors', () => { { id: '4-1abZUKu7LncRZi', compositeId: 'composite-1', - date: '2025-04-30T00:00:00.000Z', - attributes: { invoiceDate: '2025-04-30T00:00:00.000Z' }, + date: '04/30/2025', }, { id: '4-1abZUKu7LncRZj', compositeId: 'composite-1', - date: '2025-03-15T00:00:00.000Z', - invoiceDate: '2025-03-15T00:00:00.000Z', + date: '03/15/2025', }, { id: '4-1abZUKu7LncRZk', compositeId: 'composite-2', - date: '2025-02-01T00:00:00.000Z', + date: '02/01/2025', }, ], }, }); const rows = selectLighthousePreviousStatements(state); - expect(rows).to.have.lengthOf(3); + expect(rows).to.have.lengthOf(2); expect(rows[0]).to.deep.include({ - id: '4-1abZUKu7LncRZi', - invoiceDate: '2025-04-30T00:00:00.000Z', + statementId: 'composite-1', + date: 'May 1, 2025', }); expect(rows[1]).to.deep.include({ - id: '4-1abZUKu7LncRZj', - invoiceDate: '2025-03-15T00:00:00.000Z', - }); - expect(rows[2]).to.deep.include({ - id: '4-1abZUKu7LncRZk', - invoiceDate: '2025-02-01T00:00:00.000Z', + statementId: 'composite-2', + date: 'March 1, 2025', }); }); }); @@ -238,4 +254,40 @@ describe('combined utils/selectors', () => { expect(slice.statementsPending).to.be.false; }); }); + + describe('selectMonthlyStatement', () => { + it('returns monthlyStatementCopay, loading flag, and error from mcp slice', () => { + const state = { + combinedPortal: { + mcp: { + monthlyStatementCopay: { id: 'mc-1' }, + isMonthlyStatementLoading: true, + monthlyStatementError: null, + }, + }, + }; + expect(selectMonthlyStatement(state)).to.deep.equal({ + copay: { id: 'mc-1' }, + isLoading: true, + error: null, + }); + }); + + it('returns null copay when not loaded', () => { + const state = { + combinedPortal: { + mcp: { + monthlyStatementCopay: null, + isMonthlyStatementLoading: false, + monthlyStatementError: { title: 'x' }, + }, + }, + }; + expect(selectMonthlyStatement(state)).to.deep.equal({ + copay: null, + isLoading: false, + error: { title: 'x' }, + }); + }); + }); }); diff --git a/src/applications/combined-debt-portal/combined/utils/helpers.js b/src/applications/combined-debt-portal/combined/utils/helpers.js index 9c056ae8cb94..78a50d7d0fe8 100644 --- a/src/applications/combined-debt-portal/combined/utils/helpers.js +++ b/src/applications/combined-debt-portal/combined/utils/helpers.js @@ -1,7 +1,16 @@ import React from 'react'; import FEATURE_FLAG_NAMES from 'platform/utilities/feature-toggles/featureFlagNames'; import { toggleValues } from 'platform/site-wide/feature-toggles/selectors'; -import { addDays, format, isBefore, isEqual, isValid } from 'date-fns'; +import { + addDays, + addMonths, + format, + isBefore, + isEqual, + isValid, + parse, + startOfMonth, +} from 'date-fns'; import { getMedicalCenterNameByID } from 'platform/utilities/medical-centers/medical-centers'; import { templates } from '@department-of-veterans-affairs/platform-pdf/exports'; import * as Sentry from '@sentry/browser'; @@ -58,7 +67,8 @@ export const selectCopayDetailFetchError = state => /** * Helper function to consisently format date strings * - * @param {string} date - date string or date type + * @param {string|Date} date - date string or Date (VBS `pSStatementDate` as MMddyyyy, + * slash or ISO strings, or Date) * @returns formatted date string; example: * - January 1, 2021 */ @@ -83,6 +93,57 @@ export const formatDate = date => { return isValid(newDate) ? format(new Date(newDate), 'MMMM d, y') : ''; }; +/** + * Parse VBS/UI copay date strings reliably across browsers (used by first-of-month helpers). + * + * @param {string} dateStr - `pSStatementDate` (MMddyyyy), `pSStatementDateOutput` (MM/dd/yyyy), ISO-like + * @returns {Date|null} + */ +const parseCopayDateString = dateStr => { + if (dateStr == null || dateStr === '') return null; + const trimmed = String(dateStr).trim(); + + if (/^\d{8}$/.test(trimmed)) { + const d = parse(trimmed, 'MMddyyyy', new Date()); + return isValid(d) ? d : null; + } + + const slashMatch = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (slashMatch) { + const month = Number(slashMatch[1]); + const day = Number(slashMatch[2]); + const year = Number(slashMatch[3]); + const d = new Date(year, month - 1, day); + if (!isValid(d)) return null; + if ( + d.getFullYear() !== year || + d.getMonth() !== month - 1 || + d.getDate() !== day + ) { + return null; + } + return d; + } + + const normalized = new Date(trimmed.replace(/-/g, '/')); + return isValid(normalized) ? normalized : null; +}; + +/** + * First calendar day of the month *after* the month containing the copay date + * (e.g. Feb 22 → March 1). + */ +export const firstDayOfFollowingMonthFromCopayDate = ( + dateStr, + outputFormat = 'MMMM d, yyyy', +) => { + if (!dateStr) return ''; + const parsed = parseCopayDateString(dateStr); + if (!parsed) return ''; + const followingMonthStart = addMonths(startOfMonth(parsed), 1); + return format(followingMonthStart, outputFormat); +}; + export const formatISODateToMMDDYYYY = isoString => { if (!isoString) return 'N/A'; const date = new Date(isoString); @@ -94,6 +155,19 @@ export const formatISODateToMMDDYYYY = isoString => { return `${month}/${day}/${year}`; }; +export const parseStatementDateForDownload = raw => { + if (raw == null || raw === '') return null; + const trimmed = String(raw).trim(); + if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) { + const asDate = new Date(trimmed); + if (!isValid(asDate)) { + return parseCopayDateString(trimmed); + } + return parseCopayDateString(formatISODateToMMDDYYYY(trimmed)); + } + return parseCopayDateString(trimmed); +}; + export const currency = amount => { const formatter = new Intl.NumberFormat('en-US', { style: 'currency', diff --git a/src/applications/combined-debt-portal/combined/utils/selectors.js b/src/applications/combined-debt-portal/combined/utils/selectors.js index 842948520c34..cb373a825ccf 100644 --- a/src/applications/combined-debt-portal/combined/utils/selectors.js +++ b/src/applications/combined-debt-portal/combined/utils/selectors.js @@ -5,8 +5,12 @@ import { groupBy, orderBy } from 'lodash'; import { getCopaySummaryStatements, getCopayDetailStatement, + getMonthlyStatementCopay, } from '../actions/copays'; -import { selectUseLighthouseCopays } from './helpers'; +import { + selectUseLighthouseCopays, + firstDayOfFollowingMonthFromCopayDate, +} from './helpers'; import { groupCopaysByMonth } from './vbsCopayStatements'; export const selectCopayDetail = state => @@ -67,9 +71,17 @@ export const selectVbsStatementGroup = createSelector( }, ); +export const groupVbsCopaysByStatements = grouped => + grouped.map(group => ({ + statementId: group.compositeId, + date: firstDayOfFollowingMonthFromCopayDate( + group.copays[0].pSStatementDateOutput ?? '', + ), + })); + export const useVbsCurrentStatement = () => { const dispatch = useDispatch(); - const { parentCopayId, statementId } = useParams(); + const { parentCopayId, id: statementId } = useParams(); const statementsLoaded = useSelector(selectMcpStatementsLoaded); const statementsPending = useSelector(selectMcpStatementsPending); @@ -94,14 +106,6 @@ export const useVbsCurrentStatement = () => { }; }; -export const groupVbsCopaysByStatements = grouped => - grouped.flatMap(group => - group.copays.map(copay => ({ - id: copay.id, - pSStatementDateOutput: copay.pSStatementDateOutput, - })), - ); - const sortCopaysByDateDesc = copays => orderBy(copays, c => new Date(c.date), 'desc'); @@ -127,21 +131,34 @@ export const selectLighthouseStatementGroups = createSelector( export const selectLighthousePreviousStatements = createSelector( selectLighthouseStatementGroups, groups => - groups.flatMap(group => - group.copays.map(copay => ({ - id: copay.id, - invoiceDate: - copay.attributes?.invoiceDate ?? copay.invoiceDate ?? copay.date, - })), - ), + groups.map(group => { + const lead = group.copays[0]; + return { + statementId: group.statementId, + date: firstDayOfFollowingMonthFromCopayDate(lead.date ?? ''), + }; + }), ); +export const selectMonthlyStatement = state => ({ + copay: state.combinedPortal.mcp.monthlyStatementCopay, + isLoading: state.combinedPortal.mcp.isMonthlyStatementLoading, + error: state.combinedPortal.mcp.monthlyStatementError, +}); + export const useLighthouseMonthlyStatement = () => { const dispatch = useDispatch(); - const { statementId } = useParams(); + const { parentCopayId, id: statementId } = useParams(); const copayDetail = useSelector(selectCopayDetail); - const isLoading = useSelector(selectIsCopayDetailLoading); + const isCopayDetailLoading = useSelector(selectIsCopayDetailLoading); + const statementsLoaded = useSelector(selectMcpStatementsLoaded); + const statementsPending = useSelector(selectMcpStatementsPending); + const { + copay: monthlyStatementCopay, + isLoading: isMonthlyStatementLoading, + error: monthlyStatementError, + } = useSelector(selectMonthlyStatement); const groups = useSelector(selectLighthouseStatementGroups); const currentGroup = groups.find(g => g.statementId === statementId) ?? null; @@ -149,18 +166,34 @@ export const useLighthouseMonthlyStatement = () => { const mostRecentCopayId = mostRecentCopay?.id ?? null; const needsCopayDetail = - !isLoading && - mostRecentCopayId != null && - (copayDetail?.id == null || copayDetail.id !== mostRecentCopayId); + !isCopayDetailLoading && copayDetail?.id !== parentCopayId; + + const needsMonthlyStatementCopay = + !!mostRecentCopayId && + !monthlyStatementError && + !isMonthlyStatementLoading && + String(monthlyStatementCopay?.id) !== String(mostRecentCopayId); + + if (!statementsPending && !statementsLoaded) { + dispatch(getCopaySummaryStatements()); + } if (needsCopayDetail) { - dispatch(getCopayDetailStatement(mostRecentCopayId)); + dispatch(getCopayDetailStatement(parentCopayId)); + } + + if (needsMonthlyStatementCopay) { + dispatch(getMonthlyStatementCopay(mostRecentCopayId)); } return { currentGroup, - mostRecentCopay, copayDetail, - isLoading, + monthlyStatementCopay, + monthlyStatementError, + isLoading: + isCopayDetailLoading || + isMonthlyStatementLoading || + (!statementsLoaded && statementsPending), }; }; diff --git a/src/applications/combined-debt-portal/eng.json b/src/applications/combined-debt-portal/eng.json index 561291fe903d..0164ecd3c579 100644 --- a/src/applications/combined-debt-portal/eng.json +++ b/src/applications/combined-debt-portal/eng.json @@ -32,6 +32,13 @@ "resolve-page": { "error-title": "We can't access your current copay right now.", "error-body": "We're sorry. Something went wrong on our end. Check back soon." + }, + "monthly-statement": { + "subtitle": "Copay bill for {{ facility }}", + "previous-balance": "Previous balance: {{ balance }}", + "payments-received": "Payments received: {{ payments }}", + "loading": "Loading features...", + "error": "We couldn't load this statement. Return to your copay balances and open the statement again." } }, "diaryCodes": { diff --git a/src/applications/combined-debt-portal/medical-copays/components/AccountSummary.jsx b/src/applications/combined-debt-portal/medical-copays/components/AccountSummary.jsx index bcb713b5ae98..3eba5a20ab2f 100644 --- a/src/applications/combined-debt-portal/medical-copays/components/AccountSummary.jsx +++ b/src/applications/combined-debt-portal/medical-copays/components/AccountSummary.jsx @@ -1,38 +1,42 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; import { currency } from '../../combined/utils/helpers'; +import { splitAccountNumber } from './HowToPay'; export const AccountSummary = ({ acctNum, - paymentsReceived, previousBalance, + paymentsReceived, }) => { + const { t } = useTranslation(); return (
-

- Account summary -

Copay details

  • - {`Previous balance: ${currency(previousBalance)}`} + {t('mcp.monthly-statement.previous-balance', { + balance: currency(previousBalance), + })}
  • - {`Payments received: ${currency(Math.abs(paymentsReceived))}`} + {t('mcp.monthly-statement.payments-received', { + payments: currency(Math.abs(paymentsReceived)), + })}

Account number

-

{acctNum}

+

+ {splitAccountNumber(acctNum) + .filter(Boolean) + .join(' ')} +

); }; @@ -40,7 +44,7 @@ export const AccountSummary = ({ AccountSummary.propTypes = { acctNum: PropTypes.string, paymentsReceived: PropTypes.number, - previousBalance: PropTypes.number, + balance: PropTypes.number, }; export default AccountSummary; diff --git a/src/applications/combined-debt-portal/medical-copays/components/DownloadStatement.jsx b/src/applications/combined-debt-portal/medical-copays/components/DownloadStatement.jsx index 7841a98d3f9f..6f099fb2296c 100644 --- a/src/applications/combined-debt-portal/medical-copays/components/DownloadStatement.jsx +++ b/src/applications/combined-debt-portal/medical-copays/components/DownloadStatement.jsx @@ -2,8 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import recordEvent from 'platform/monitoring/record-event'; import environment from 'platform/utilities/environment'; -import { parse } from 'date-fns'; -import { formatDate } from '../../combined/utils/helpers'; +import { + formatDate, + parseStatementDateForDownload, +} from '../../combined/utils/helpers'; const handleDownloadClick = date => { return recordEvent({ @@ -12,12 +14,21 @@ const handleDownloadClick = date => { }); }; -const DownloadStatement = ({ statementId, statementDate, fullName }) => { - const parsedStatementDate = parse(statementDate, 'MMddyyyy', new Date()); - const formattedStatementDate = formatDate(parsedStatementDate); +const DownloadStatement = ({ + statementId, + statementDate, + fullName, + billReference = '', +}) => { + const parsed = parseStatementDateForDownload(statementDate); + const formattedStatementDate = parsed ? formatDate(parsed) : ''; - const downloadFileName = `${fullName} Veterans Medical copay statement dated ${formattedStatementDate}.pdf`; - const downloadText = `Download your ${formattedStatementDate} statement`; + const downloadFileName = formattedStatementDate + ? `${fullName} Veterans Medical copay statement dated ${formattedStatementDate}.pdf` + : `${fullName} Veterans Medical copay statement.pdf`; + const downloadText = formattedStatementDate + ? `Download your ${formattedStatementDate} ${billReference} statement` + : `Download your${billReference ? ` ${billReference}` : ''} statement`; const pdfStatementUri = encodeURI( `${ environment.API_URL @@ -49,8 +60,9 @@ const DownloadStatement = ({ statementId, statementDate, fullName }) => { DownloadStatement.propTypes = { fullName: PropTypes.string.isRequired, - statementDate: PropTypes.string.isRequired, + statementDate: PropTypes.string, statementId: PropTypes.string.isRequired, + billReference: PropTypes.string, }; export default DownloadStatement; diff --git a/src/applications/combined-debt-portal/medical-copays/components/HTMLStatementLink.jsx b/src/applications/combined-debt-portal/medical-copays/components/HTMLStatementLink.jsx index 648db3534782..2abb3c47a9e6 100644 --- a/src/applications/combined-debt-portal/medical-copays/components/HTMLStatementLink.jsx +++ b/src/applications/combined-debt-portal/medical-copays/components/HTMLStatementLink.jsx @@ -8,7 +8,7 @@ import { formatDate } from '../../combined/utils/helpers'; const HTMLStatementLink = ({ id, copayId, statementDate }) => { const history = useHistory(); - const to = `/copay-balances/${id}/statement`; + const to = `/copay-balances/${copayId}/previous-statements/${id}`; return (
  • diff --git a/src/applications/combined-debt-portal/medical-copays/components/PreviousStatements.jsx b/src/applications/combined-debt-portal/medical-copays/components/PreviousStatements.jsx index 097b24d23664..f11af3229fec 100644 --- a/src/applications/combined-debt-portal/medical-copays/components/PreviousStatements.jsx +++ b/src/applications/combined-debt-portal/medical-copays/components/PreviousStatements.jsx @@ -2,11 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import HTMLStatementLink from './HTMLStatementLink'; -const PreviousStatements = ({ - previousStatements, - shouldUseLighthouseCopays, - copayId, -}) => { +const PreviousStatements = ({ previousStatements, copayId }) => { if (!previousStatements?.length) return null; return ( @@ -24,14 +20,10 @@ const PreviousStatements = ({ > {previousStatements.map(statement => ( ))} @@ -42,7 +34,6 @@ const PreviousStatements = ({ PreviousStatements.propTypes = { copayId: PropTypes.string, previousStatements: PropTypes.array, - shouldUseLighthouseCopays: PropTypes.bool, }; export default PreviousStatements; diff --git a/src/applications/combined-debt-portal/medical-copays/components/StatementCharges.jsx b/src/applications/combined-debt-portal/medical-copays/components/StatementCharges.jsx index 91ae9a6b6b03..f69f0217a644 100644 --- a/src/applications/combined-debt-portal/medical-copays/components/StatementCharges.jsx +++ b/src/applications/combined-debt-portal/medical-copays/components/StatementCharges.jsx @@ -1,6 +1,6 @@ import React, { useRef } from 'react'; import PropTypes from 'prop-types'; -import { subDays } from 'date-fns'; +import { subDays, subMonths } from 'date-fns'; import { formatDate, removeNonBreakingSpaces, @@ -8,7 +8,12 @@ import { import Pagination from '../../combined/components/Pagination'; import usePagination from '../../combined/hooks/usePagination'; -const StatementCharges = ({ copay, showCurrentStatementHeader = false }) => { +const StatementCharges = ({ + copay, + lineItems, + date, + showCurrentStatementHeader = false, +}) => { const formatAmountSingleLine = amount => { const cleanedAmount = removeNonBreakingSpaces(amount) .replace('-', '') @@ -17,12 +22,20 @@ const StatementCharges = ({ copay, showCurrentStatementHeader = false }) => { }; const tableRef = useRef(null); - const initialDate = new Date(copay.pSStatementDateOutput); - const statementDate = formatDate(initialDate); - const previousCopayStartDate = formatDate(subDays(initialDate, 30)); + + const dateRange = date + ? { + initialDate: subDays(new Date(date), 1), + finalDate: subMonths(new Date(date), 1), + } + : { + initialDate: subDays(new Date(copay.pSStatementDateOutput), 1), + finalDate: subDays(new Date(copay.pSStatementDateOutput), 30), + }; // Filter out empty charges - const filteredDetails = copay.details.filter( + const copayDetails = lineItems ?? copay.details; + const filteredDetails = copayDetails.filter( item => typeof item.pDTransDescOutput === 'string' && item.pDTransDescOutput.replace(/ /g, '').trim() !== '', @@ -35,11 +48,13 @@ const StatementCharges = ({ copay, showCurrentStatementHeader = false }) => { const paginationText = pagination.getPaginationText(ITEM_TYPE); const getStatementDateRange = () => { - if (!previousCopayStartDate || !statementDate) { + if (!formatDate(dateRange.finalDate) || !dateRange.initialDate) { return 'This statement shows your current charges.'; } - return `This statement shows charges you received between ${previousCopayStartDate} and ${statementDate}.`; + return `This statement shows charges you received between ${formatDate( + dateRange.finalDate, + )} and ${formatDate(dateRange.initialDate)}.`; }; return ( diff --git a/src/applications/combined-debt-portal/medical-copays/components/StatementTable.jsx b/src/applications/combined-debt-portal/medical-copays/components/StatementTable.jsx index 57feff0fb1f5..bd51bbb5436b 100644 --- a/src/applications/combined-debt-portal/medical-copays/components/StatementTable.jsx +++ b/src/applications/combined-debt-portal/medical-copays/components/StatementTable.jsx @@ -1,18 +1,24 @@ import React, { useRef } from 'react'; import PropTypes from 'prop-types'; +import { subDays, subMonths } from 'date-fns'; import { formatDate } from '../../combined/utils/helpers'; import Pagination from '../../combined/components/Pagination'; import usePagination from '../../combined/hooks/usePagination'; -const StatementTable = ({ charges, formatCurrency, selectedCopay }) => { +const StatementTable = ({ + charges, + formatCurrency, + selectedCopay, + statementDate, +}) => { const columns = ['Date', 'Description', 'Billing Reference', 'Amount']; const normalizedCharges = charges.map(item => ({ - date: item.datePosted, - description: item.description, - reference: selectedCopay?.attributes?.billNumber, + date: item.datePosted || '', + description: item.description || '', + reference: selectedCopay?.attributes?.billNumber || '', amount: item.priceComponents?.[0]?.amount ?? 0, - provider: item.providerName, + provider: item.providerName || '', details: [], })); @@ -31,14 +37,27 @@ const StatementTable = ({ charges, formatCurrency, selectedCopay }) => { const paginationText = pagination.getPaginationText(ITEM_TYPE); const getStatementDateRange = () => { - const startDate = formatDate(selectedCopay.statementStartDate); - const endDate = formatDate(selectedCopay.statementEndDate); - - if (!startDate || !endDate) { + const dateRange = statementDate + ? { + startDate: formatDate(subMonths(new Date(statementDate), 1)), + endDate: formatDate(subDays(new Date(statementDate), 1)), + } + : { + startDate: formatDate( + subMonths(new Date(selectedCopay?.attributes?.invoiceDate), 1), + ), + endDate: formatDate( + subDays(new Date(selectedCopay?.attributes?.invoiceDate), 1), + ), + }; + + if (!dateRange.startDate || !dateRange.endDate) { return 'This statement shows your current charges.'; } - return `This statement shows charges you received between ${startDate} and ${endDate}.`; + return `This statement shows charges you received between ${ + dateRange.startDate + } and ${dateRange.endDate}.`; }; const renderDescription = charge => ( diff --git a/src/applications/combined-debt-portal/medical-copays/containers/DetailCopayPage.jsx b/src/applications/combined-debt-portal/medical-copays/containers/DetailCopayPage.jsx index cc6b3657b37a..bbc0c6238a33 100644 --- a/src/applications/combined-debt-portal/medical-copays/containers/DetailCopayPage.jsx +++ b/src/applications/combined-debt-portal/medical-copays/containers/DetailCopayPage.jsx @@ -17,7 +17,6 @@ import { formatDate, verifyCurrentBalance, setPageFocus, - formatISODateToMMDDYYYY, isAnyElementFocused, DEFAULT_COPAY_ATTRIBUTES, } from '../../combined/utils/helpers'; @@ -326,7 +325,7 @@ const DetailCopayPage = ({ match }) => { statementId={selectedId} statementDate={ shouldUseLighthouseCopays - ? formatISODateToMMDDYYYY(copayAttributes.INVOICE_DATE) + ? copayAttributes.INVOICE_DATE ?? '' : selectedCopay.pSStatementDate } fullName={fullName} @@ -335,7 +334,6 @@ const DetailCopayPage = ({ match }) => { {hasPreviousStatements && ( )} diff --git a/src/applications/combined-debt-portal/medical-copays/containers/HTMLStatementPage.jsx b/src/applications/combined-debt-portal/medical-copays/containers/HTMLStatementPage.jsx index f2bbcd20179c..c06b23ff5a60 100644 --- a/src/applications/combined-debt-portal/medical-copays/containers/HTMLStatementPage.jsx +++ b/src/applications/combined-debt-portal/medical-copays/containers/HTMLStatementPage.jsx @@ -81,7 +81,7 @@ const HTMLStatementPage = ({ match }) => { label: `${prevPage}`, }, { - href: `/manage-va-debt/summary/copay-balances/${selectedId}/statement`, + href: `/manage-va-debt/summary/copay-balances/${copayId}/previous-statements/${selectedId}`, label: `${title}`, }, ]} diff --git a/src/applications/combined-debt-portal/medical-copays/containers/MonthlyStatementPage.jsx b/src/applications/combined-debt-portal/medical-copays/containers/MonthlyStatementPage.jsx new file mode 100644 index 000000000000..406976e154a3 --- /dev/null +++ b/src/applications/combined-debt-portal/medical-copays/containers/MonthlyStatementPage.jsx @@ -0,0 +1,312 @@ +import React, { useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + VaBreadcrumbs, + VaLoadingIndicator, +} from '@department-of-veterans-affairs/component-library/dist/react-bindings'; +import { + setPageFocus, + isAnyElementFocused, + currency, + selectUseLighthouseCopays, +} from '../../combined/utils/helpers'; +import { + useLighthouseMonthlyStatement, + useVbsCurrentStatement, + selectAllCopays, +} from '../../combined/utils/selectors'; +import { + buildLegacyStatementAttributes, + buildLighthouseStatementAttributes, +} from '../utils/monthlyStatementAttributes'; +import Modals from '../../combined/components/Modals'; +import StatementAddresses from '../components/StatementAddresses'; +import AccountSummary from '../components/AccountSummary'; +import StatementTable from '../components/StatementTable'; +import DownloadStatement from '../components/DownloadStatement'; +import NeedHelpCopay from '../components/NeedHelpCopay'; +import useHeaderPageTitle from '../../combined/hooks/useHeaderPageTitle'; +import i18nCombinedDebtPortal from '../../i18n'; +import StatementCharges from '../components/StatementCharges'; + +const DEFAULT_STATEMENT_ATTRIBUTES = {}; + +const getBreadcrumbs = ( + statementAttributes, + routeCopayId, + routeStatementId, +) => { + const latestCopay = statementAttributes.LATEST_COPAY || {}; + return [ + { href: '/', label: 'Home' }, + { href: '/manage-va-debt/summary', label: 'Overpayments and copays' }, + { href: '/manage-va-debt/summary/copay-balances', label: 'Copay balances' }, + { + href: `/manage-va-debt/summary/copay-balances/${routeCopayId ?? + latestCopay.id}`, + label: statementAttributes.PREV_PAGE, + }, + { + href: `/manage-va-debt/summary/copay-balances/${routeCopayId ?? + latestCopay.id}/previous-statements/${routeStatementId ?? + latestCopay.statementId}`, + label: statementAttributes.TITLE, + }, + ]; +}; + +const LoadingError = () => ( +
    +

    {i18nCombinedDebtPortal.t('mcp.monthly-statement.error')}

    +
    +); + +const LoadingIndicator = () => ( + +); + +const MonthlyStatementPageContent = ({ + monthlyStatement, + statementAttributes, + isLoading, + copayDetail, + shouldUseLighthouseCopays, + monthlyStatementFetchError, +}) => { + const { parentCopayId, id: statementId } = useParams(); + const { t } = useTranslation(); + const userFullName = useSelector(state => state.user.profile.userFullName); + const fullName = userFullName.middle + ? `${userFullName.first} ${userFullName.middle} ${userFullName.last}` + : `${userFullName.first} ${userFullName.last}`; + + const copays = monthlyStatement?.copays ?? []; + const mostRecentVBSCopay = copays[0] ?? null; + + useHeaderPageTitle(statementAttributes.TITLE); + + useEffect(() => { + if (!isAnyElementFocused()) setPageFocus(); + }, []); + + if (isLoading) return ; + + if (monthlyStatementFetchError) { + const facilityName = copayDetail?.attributes?.facility?.name; + const breadcrumbStatementAttributes = { + ...statementAttributes, + PREV_PAGE: facilityName + ? `Copay for ${facilityName}` + : statementAttributes.PREV_PAGE, + LATEST_COPAY: statementAttributes.LATEST_COPAY ?? { + id: parentCopayId, + statementId, + }, + }; + + return ( + <> + +
    + +

    {t('mcp.resolve-page.error-title')}

    +

    {t('mcp.resolve-page.error-body')}

    +
    +
    + + ); + } + + if (!copays?.length) return ; + + return ( + <> + +
    +

    {statementAttributes.TITLE}

    +

    + {t('mcp.monthly-statement.subtitle', { + facility: statementAttributes.FACILITY_NAME, + })} +

    + + {shouldUseLighthouseCopays ? ( + + ) : ( + + )} + {statementAttributes.DOWNLOAD_REFERENCES?.map(download => ( + + ))} + + + + + +
    + + ); +}; + +const downloadReferencePropType = PropTypes.shape({ + date: PropTypes.string, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + reference: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}); + +const statementAttributesPropType = PropTypes.shape({ + ACCOUNT_NUMBER: PropTypes.string, + CHARGES: PropTypes.arrayOf(PropTypes.object), + DATE: PropTypes.string, + DOWNLOAD_REFERENCES: PropTypes.arrayOf(downloadReferencePropType), + FACILITY_NAME: PropTypes.string, + LATEST_COPAY: PropTypes.object, + PAYMENTS_RECEIVED: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + PREVIOUS_BALANCE: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + PREV_PAGE: PropTypes.string, + TITLE: PropTypes.string, +}); + +MonthlyStatementPageContent.propTypes = { + copayDetail: PropTypes.object, + isLoading: PropTypes.bool, + monthlyStatement: PropTypes.shape({ + copays: PropTypes.arrayOf(PropTypes.object), + }), + monthlyStatementFetchError: PropTypes.object, + shouldUseLighthouseCopays: PropTypes.bool, + statementAttributes: statementAttributesPropType, +}; + +const MonthlyStatementPageLighthouse = () => { + const { + currentGroup, + copayDetail, + monthlyStatementCopay, + monthlyStatementError, + isLoading, + } = useLighthouseMonthlyStatement(); + + const allCopays = useSelector(selectAllCopays); + + const statementAttributes = useMemo( + () => { + const copays = currentGroup?.copays ?? []; + return copays.length + ? buildLighthouseStatementAttributes({ + monthlyStatement: currentGroup, + monthlyStatementCopay, + allCopays, + }) + : DEFAULT_STATEMENT_ATTRIBUTES; + }, + [currentGroup, monthlyStatementCopay, allCopays], + ); + + return ( + + ); +}; + +const MonthlyStatementPageVbs = () => { + const { id: statementId } = useParams(); + const { monthlyStatement, isLoading } = useVbsCurrentStatement(); + + const statementAttributes = useMemo( + () => { + const copays = monthlyStatement?.copays ?? []; + return copays.length + ? buildLegacyStatementAttributes({ + copays, + statementId, + }) + : DEFAULT_STATEMENT_ATTRIBUTES; + }, + [monthlyStatement, statementId], + ); + + return ( + + ); +}; + +const MonthlyStatementPage = () => { + const shouldUseLighthouseCopays = useSelector(selectUseLighthouseCopays); + if (shouldUseLighthouseCopays === null) { + return ( + + ); + } + return shouldUseLighthouseCopays ? ( + + ) : ( + + ); +}; + +export default MonthlyStatementPage; diff --git a/src/applications/combined-debt-portal/medical-copays/containers/ResolvePage.jsx b/src/applications/combined-debt-portal/medical-copays/containers/ResolvePage.jsx index 48979553ec86..27df76f436a0 100644 --- a/src/applications/combined-debt-portal/medical-copays/containers/ResolvePage.jsx +++ b/src/applications/combined-debt-portal/medical-copays/containers/ResolvePage.jsx @@ -15,7 +15,6 @@ import { selectUseLighthouseCopays, showVHAPaymentHistory, selectCopayDetailFetchError, - formatISODateToMMDDYYYY, isAnyElementFocused, DEFAULT_COPAY_ATTRIBUTES, verifyCurrentBalance, @@ -51,6 +50,13 @@ const ResolveCopayBreadcrumbs = ({ selectedId, copayAttributes }) => ( /> ); +ResolveCopayBreadcrumbs.propTypes = { + selectedId: PropTypes.string, + copayAttributes: PropTypes.shape({ + TITLE: PropTypes.string, + }).isRequired, +}; + const ResolvePage = ({ match }) => { const dispatch = useDispatch(); const { t } = useTranslation(); @@ -206,7 +212,7 @@ const ResolvePage = ({ match }) => { statementId={selectedId} statementDate={ shouldUseLighthouseCopays - ? formatISODateToMMDDYYYY(copayAttributes.INVOICE_DATE) + ? copayAttributes.INVOICE_DATE ?? '' : selectedCopay.pSStatementDateOutput } fullName={fullName} @@ -220,8 +226,11 @@ const ResolvePage = ({ match }) => { }; ResolvePage.propTypes = { - copayDetail: PropTypes.object, - match: PropTypes.object, + match: PropTypes.shape({ + params: PropTypes.shape({ + id: PropTypes.string, + }), + }).isRequired, }; export { getResolveCopayBreadcrumbList }; export default ResolvePage; diff --git a/src/applications/combined-debt-portal/medical-copays/tests/unit/detailCopayPage.unit.spec.jsx b/src/applications/combined-debt-portal/medical-copays/tests/unit/detailCopayPage.unit.spec.jsx index a8a5da117ae9..d6a08cef9d9f 100644 --- a/src/applications/combined-debt-portal/medical-copays/tests/unit/detailCopayPage.unit.spec.jsx +++ b/src/applications/combined-debt-portal/medical-copays/tests/unit/detailCopayPage.unit.spec.jsx @@ -13,6 +13,7 @@ import FEATURE_FLAG_NAMES from 'platform/utilities/feature-toggles/featureFlagNa import i18nCombinedDebtPortal from '../../../i18n'; import eng from '../../../eng.json'; import * as copaysActions from '../../../combined/actions/copays'; +import { vbsCompositeId } from '../../../combined/utils/vbsCopayStatements'; import DetailCopayPage from '../../containers/DetailCopayPage'; const RESOLVE_PAGE_ERROR = eng['combined-debt-portal'].mcp['resolve-page']; @@ -461,11 +462,12 @@ describe('DetailCopayPage', () => { }); describe('VBS — groupVbsCopaysByStatements(useVbsGroupedCopaysByCurrentCopay)', () => { - it('loads list data via GET /v0/medical_copays once, then renders a previous-statement link per prior copay row', async () => { - // V0 list uses string statement ids (uuid-shaped); hrefs are /copay-balances/:id/statement + it('loads list data via GET /v0/medical_copays once, then renders one previous-statement link per prior month (compositeId)', async () => { const priorFebLateId = '3fa85f64-5717-4562-b3fc-2c963f66aa01'; const priorFebEarlyId = '3fa85f64-5717-4562-b3fc-2c963f66aa02'; const priorJanId = '3fa85f64-5717-4562-b3fc-2c963f66aa03'; + const febComposite = vbsCompositeId(FACILITY, 2, 2024); + const janComposite = vbsCompositeId(FACILITY, 1, 2024); const open = legacyCopay('123', '03/15/2024', { pHNewBalance: 100, @@ -503,20 +505,16 @@ describe('DetailCopayPage', () => { const list = view.getByTestId('otpp-statement-list'); const vaLinks = list.querySelectorAll('va-link'); - expect(vaLinks).to.have.length(3); + expect(vaLinks).to.have.length(2); const expected = [ { - testId: `balance-details-${priorFebLateId}-statement-view`, - href: `/copay-balances/${priorFebLateId}/statement`, + testId: `balance-details-${febComposite}-statement-view`, + href: `/copay-balances/123/previous-statements/${febComposite}`, }, { - testId: `balance-details-${priorFebEarlyId}-statement-view`, - href: `/copay-balances/${priorFebEarlyId}/statement`, - }, - { - testId: `balance-details-${priorJanId}-statement-view`, - href: `/copay-balances/${priorJanId}/statement`, + testId: `balance-details-${janComposite}-statement-view`, + href: `/copay-balances/123/previous-statements/${janComposite}`, }, ]; @@ -549,7 +547,7 @@ describe('DetailCopayPage', () => { }); describe('Lighthouse — selectLighthousePreviousStatements', () => { - it('renders a link per attributes.associatedStatements row', () => { + it('renders one link per compositeId (monthly statement) from associatedStatements', () => { const mockStatement = { id: '123', attributes: { @@ -574,13 +572,16 @@ describe('DetailCopayPage', () => { compositeId: '648-2-2024', date: '2024-02-15T00:00:00.000Z', attributes: { - invoiceDate: '2024-02-15T00:00:00.000Z', + invoiceDate: '2024-02-15', }, }, { id: '4-assoc-b', compositeId: '648-1-2024', date: '2024-01-10T00:00:00.000Z', + attributes: { + invoiceDate: '2024-01-10', + }, }, ], }, @@ -619,17 +620,17 @@ describe('DetailCopayPage', () => { expect(vaLinks).to.have.length(2); const linkA = view.getByTestId( - 'balance-details-4-assoc-a-statement-view', + 'balance-details-648-2-2024-statement-view', ); const linkB = view.getByTestId( - 'balance-details-4-assoc-b-statement-view', + 'balance-details-648-1-2024-statement-view', ); expect(linkA.getAttribute('href')).to.equal( - '/copay-balances/4-assoc-a/statement', + '/copay-balances/123/previous-statements/648-2-2024', ); expect(linkB.getAttribute('href')).to.equal( - '/copay-balances/4-assoc-b/statement', + '/copay-balances/123/previous-statements/648-1-2024', ); const labelA = linkA.getAttribute('text') ?? linkA.textContent ?? ''; const labelB = linkB.getAttribute('text') ?? linkB.textContent ?? ''; diff --git a/src/applications/combined-debt-portal/medical-copays/tests/unit/monthlyStatementAttributes.unit.spec.jsx b/src/applications/combined-debt-portal/medical-copays/tests/unit/monthlyStatementAttributes.unit.spec.jsx new file mode 100644 index 000000000000..855a466843fd --- /dev/null +++ b/src/applications/combined-debt-portal/medical-copays/tests/unit/monthlyStatementAttributes.unit.spec.jsx @@ -0,0 +1,184 @@ +import { expect } from 'chai'; +import { + statementTitleAndDateFields, + buildLegacyStatementAttributes, + buildLighthouseStatementAttributes, +} from '../../utils/monthlyStatementAttributes'; + +describe('medical-copays/utils/monthlyStatementAttributes', () => { + describe('statementTitleAndDateFields', () => { + it('uses first day of the month after the copay month (VBS slash date)', () => { + const result = statementTitleAndDateFields('03/15/2024'); + expect(result.titleLabel).to.equal('April 1, 2024'); + expect(result.dateField).to.equal('04/01/2024'); + }); + + it('maps Feb 22 copay to March 1 statement month', () => { + const result = statementTitleAndDateFields('02/22/2025'); + expect(result.titleLabel).to.equal('March 1, 2025'); + expect(result.dateField).to.equal('03/01/2025'); + }); + }); + + describe('buildLegacyStatementAttributes', () => { + it('aggregates VBS copays into statement fields, downloads, and charges', () => { + const primaryCopay = { + id: 'copay-newer', + station: { facilityName: 'Test VA Medical Center' }, + pSStatementDateOutput: '03/10/2024', + pSStatementDate: '03102024', + accountNumber: 'ACC-100', + details: [ + { + pDTransDescOutput: 'Office visit', + pDTransAmt: 40, + pDRefNo: 'REF-1', + }, + { pDTransDescOutput: ' hidden row', pDTransAmt: 1 }, + ], + pHPrevBal: 0, + pHTotCharges: 15, + }; + const oldestCopay = { + id: 'copay-oldest', + station: { facilityName: 'Test VA Medical Center' }, + pSStatementDateOutput: '02/01/2024', + pSStatementDate: '02012024', + details: [], + pHPrevBal: 200, + pHTotCharges: 10, + }; + + const attrs = buildLegacyStatementAttributes({ + copays: [primaryCopay, oldestCopay], + statementId: 'composite-route-id', + }); + + expect(attrs.LATEST_COPAY.statementId).to.equal('composite-route-id'); + expect(attrs.LATEST_COPAY.id).to.equal('copay-newer'); + expect(attrs.FACILITY_NAME).to.equal('Test VA Medical Center'); + expect(attrs.PREV_PAGE).to.equal('Copay for Test VA Medical Center'); + expect(attrs.ACCOUNT_NUMBER).to.equal('ACC-100'); + expect(attrs.TITLE).to.equal('April 1, 2024 statement'); + expect(attrs.DATE).to.equal('04/01/2024'); + expect(attrs.PREVIOUS_BALANCE).to.equal(200); + expect(attrs.PAYMENTS_RECEIVED).to.equal(25); + expect(attrs.CHARGES).to.have.lengthOf(1); + expect(attrs.CHARGES[0].pDTransDescOutput).to.equal('Office visit'); + expect(attrs.DOWNLOAD_REFERENCES).to.deep.equal([ + { + id: 'copay-newer', + reference: 'REF-1', + date: '03/10/2024', + }, + { + id: 'copay-oldest', + reference: undefined, + date: '02/01/2024', + }, + ]); + }); + }); + + describe('buildLighthouseStatementAttributes', () => { + it('builds attributes from monthlyStatementCopay; previous balance from oldest-in-group copay in allCopays', () => { + const monthlyStatementCopay = { + id: 'detail-1', + attributes: { + invoiceDate: '2025-02-28', + facility: { name: 'Lighthouse VA' }, + accountNumber: 'LH-55', + principalPaid: 12.5, + previousUnpaidBalance: 88, + billNumber: 'BILL-9', + }, + }; + const allCopays = [ + { + id: 'detail-1', + attributes: { previousUnpaidBalance: 88 }, + }, + { + id: 'row-b', + attributes: { previousUnpaidBalance: 42 }, + }, + ]; + const monthlyStatement = { + copays: [ + { + id: 'row-a', + date: '2025-02-01', + lineItems: [ + { + billNumber: 'B1', + description: 'Lab', + priceComponents: [{ amount: 30 }], + }, + ], + }, + { + id: 'row-b', + date: '2025-01-15', + lineItems: [ + { + billNumber: 'B2', + description: 'Rx', + priceComponents: [{ amount: 5 }], + }, + ], + }, + ], + }; + + const attrs = buildLighthouseStatementAttributes({ + monthlyStatement, + monthlyStatementCopay, + allCopays, + }); + + expect(attrs.LATEST_COPAY).to.deep.equal(monthlyStatementCopay); + expect(attrs.FACILITY_NAME).to.equal('Lighthouse VA'); + expect(attrs.ACCOUNT_NUMBER).to.equal('LH-55'); + expect(attrs.PREVIOUS_BALANCE).to.equal(42); + expect(attrs.PAYMENTS_RECEIVED).to.equal(12.5); + expect(attrs.TITLE).to.equal('March 1, 2025 statement'); + expect(attrs.DATE).to.equal('03/01/2025'); + expect(attrs.PREV_PAGE).to.equal('Copay for Lighthouse VA'); + expect(attrs.CHARGES).to.have.lengthOf(2); + expect(attrs.CHARGES[0].description).to.equal('Lab'); + expect(attrs.CHARGES[1].description).to.equal('Rx'); + expect(attrs.DOWNLOAD_REFERENCES).to.deep.equal([ + { + id: 'row-a', + reference: undefined, + date: '2025-02-01', + }, + { + id: 'row-b', + reference: undefined, + date: '2025-01-15', + }, + ]); + }); + + it('handles empty monthlyStatement.copays', () => { + const monthlyStatementCopay = { + id: 'x', + attributes: { + invoiceDate: '2025-01-15', + facility: { name: 'VA' }, + accountNumber: 'A1', + principalPaid: 0, + }, + }; + const attrs = buildLighthouseStatementAttributes({ + monthlyStatement: { copays: [] }, + monthlyStatementCopay, + allCopays: [], + }); + expect(attrs.CHARGES).to.deep.equal([]); + expect(attrs.DOWNLOAD_REFERENCES).to.deep.equal([]); + expect(attrs.PREVIOUS_BALANCE).to.be.undefined; + }); + }); +}); diff --git a/src/applications/combined-debt-portal/medical-copays/tests/unit/previousStatements.unit.spec.jsx b/src/applications/combined-debt-portal/medical-copays/tests/unit/previousStatements.unit.spec.jsx index a0db187b0ab2..6ca366708397 100644 --- a/src/applications/combined-debt-portal/medical-copays/tests/unit/previousStatements.unit.spec.jsx +++ b/src/applications/combined-debt-portal/medical-copays/tests/unit/previousStatements.unit.spec.jsx @@ -13,27 +13,30 @@ const copayIds = { legacyB: '648-stmt-2024-02', }; -const vhaStatement = (id, invoiceDate) => ({ id, invoiceDate }); +/** Parent facility/copay id — matches `/copay-balances/:copayId/previous-statements/:statementId` */ +const PARENT_COPAY_ID = 'parent-copay-123'; -const legacyStatement = (id, date) => ({ - id, - pSStatementDateOutput: date, -}); +const statementHref = statementId => + `/copay-balances/${PARENT_COPAY_ID}/previous-statements/${statementId}`; + +const vhaStatement = (statementId, date) => ({ statementId, date }); + +const legacyStatement = (statementId, date) => ({ statementId, date }); const renderWithRouter = component => render({component}); describe('PreviousStatements', () => { - describe('when shouldUseLighthouseCopays is true', () => { + describe('Lighthouse-shaped previousStatements (statementId + date)', () => { it('should render when recentStatements exist', () => { const { getByTestId } = renderWithRouter( , ); @@ -49,8 +52,8 @@ describe('PreviousStatements', () => { it('should return null when recentStatements is empty', () => { const { queryByTestId } = renderWithRouter( , ); @@ -60,13 +63,13 @@ describe('PreviousStatements', () => { it('should not sort statements (render in original order)', () => { const { getByTestId } = renderWithRouter( , ); @@ -76,24 +79,24 @@ describe('PreviousStatements', () => { // Verify order is preserved by checking the full href of each va-link in sequence expect(items[0].querySelector('va-link').getAttribute('href')).to.equal( - `/copay-balances/${copayIds.jan}/statement`, + statementHref(copayIds.jan), ); expect(items[1].querySelector('va-link').getAttribute('href')).to.equal( - `/copay-balances/${copayIds.mar}/statement`, + statementHref(copayIds.mar), ); expect(items[2].querySelector('va-link').getAttribute('href')).to.equal( - `/copay-balances/${copayIds.feb}/statement`, + statementHref(copayIds.feb), ); expect(items[3].querySelector('va-link').getAttribute('href')).to.equal( - `/copay-balances/${copayIds.apr}/statement`, + statementHref(copayIds.apr), ); }); it('should render correct heading and description text', () => { const { getByRole, getByText } = renderWithRouter( , ); @@ -110,10 +113,11 @@ describe('PreviousStatements', () => { }); }); - describe('when shouldUseLighthouseCopays is false', () => { + describe('VBS-shaped previousStatements (statementId + date)', () => { it('should render when previous statements exist', () => { const { getByTestId } = renderWithRouter( { it('should return null when previousStatements is empty', () => { const { queryByTestId } = renderWithRouter( - , + , ); expect(queryByTestId('view-statements')).to.not.exist; @@ -139,11 +146,11 @@ describe('PreviousStatements', () => { it('should render statements in the order provided', () => { const { getByTestId } = renderWithRouter( , ); @@ -151,10 +158,10 @@ describe('PreviousStatements', () => { const items = list.querySelectorAll('li'); expect(items).to.have.lengthOf(2); expect(items[0].querySelector('va-link').getAttribute('href')).to.equal( - `/copay-balances/${copayIds.legacyA}/statement`, + statementHref(copayIds.legacyA), ); expect(items[1].querySelector('va-link').getAttribute('href')).to.equal( - `/copay-balances/${copayIds.legacyB}/statement`, + statementHref(copayIds.legacyB), ); }); }); @@ -162,17 +169,16 @@ describe('PreviousStatements', () => { it('should forward copayId to each HTMLStatementLink as router state (links render with correct hrefs)', () => { const { getByTestId } = renderWithRouter( , ); // copayId is threaded via history.push state in HTMLStatementLink, not a DOM attribute. - // We verify both links render and point to the correct copay hrefs. + // We verify both links render and point to the correct monthly statement hrefs. expect(getByTestId(`balance-details-${copayIds.jan}-statement-view`)).to .exist; @@ -182,12 +188,16 @@ describe('PreviousStatements', () => { getByTestId( `balance-details-${copayIds.jan}-statement-view`, ).getAttribute('href'), - ).to.equal(`/copay-balances/${copayIds.jan}/statement`); + ).to.equal( + `/copay-balances/parent-copay-123/previous-statements/${copayIds.jan}`, + ); expect( getByTestId( `balance-details-${copayIds.feb}-statement-view`, ).getAttribute('href'), - ).to.equal(`/copay-balances/${copayIds.feb}/statement`); + ).to.equal( + `/copay-balances/parent-copay-123/previous-statements/${copayIds.feb}`, + ); }); it('should return null when previousStatements is undefined', () => { diff --git a/src/applications/combined-debt-portal/medical-copays/tests/unit/reducers.unit.spec.jsx b/src/applications/combined-debt-portal/medical-copays/tests/unit/reducers.unit.spec.jsx index 9dc1b5a25480..701a774a920c 100644 --- a/src/applications/combined-debt-portal/medical-copays/tests/unit/reducers.unit.spec.jsx +++ b/src/applications/combined-debt-portal/medical-copays/tests/unit/reducers.unit.spec.jsx @@ -8,6 +8,9 @@ import { MCP_DETAIL_FETCH_INIT, MCP_DETAIL_FETCH_SUCCESS, MCP_DETAIL_FETCH_FAILURE, + MCP_MONTHLY_STATEMENT_FETCH_INIT, + MCP_MONTHLY_STATEMENT_FETCH_SUCCESS, + MCP_MONTHLY_STATEMENT_FETCH_FAILURE, } from '../../../combined/actions/copays'; describe('Medical Copays Reducer', () => { @@ -16,6 +19,13 @@ describe('Medical Copays Reducer', () => { expect(reducedState.selectedStatement).to.be.null; }); + it('initial state has monthly statement fields cleared', () => { + const reducedState = reducer(undefined, { type: '@@INIT' }); + expect(reducedState.monthlyStatementCopay).to.be.null; + expect(reducedState.isMonthlyStatementLoading).to.be.false; + expect(reducedState.monthlyStatementError).to.be.null; + }); + it('MCP_DETAIL_FETCH_INIT clears selectedStatement and sets isCopayDetailLoading', () => { const withSelection = reducer(undefined, { type: MCP_DETAIL_FETCH_SUCCESS, @@ -90,4 +100,43 @@ describe('Medical Copays Reducer', () => { expect(reducedState.pending).to.be.false; expect(reducedState.error).to.deep.equal(errorResponse); }); + + it('MCP_MONTHLY_STATEMENT_FETCH_INIT clears prior copay and sets loading', () => { + const withMonthly = reducer(undefined, { + type: MCP_MONTHLY_STATEMENT_FETCH_SUCCESS, + response: { data: { id: 'prev' } }, + }); + expect(withMonthly.monthlyStatementCopay).to.deep.equal({ id: 'prev' }); + + const afterInit = reducer(withMonthly, { + type: MCP_MONTHLY_STATEMENT_FETCH_INIT, + }); + expect(afterInit.monthlyStatementCopay).to.be.null; + expect(afterInit.isMonthlyStatementLoading).to.be.true; + expect(afterInit.monthlyStatementError).to.be.null; + }); + + it('MCP_MONTHLY_STATEMENT_FETCH_SUCCESS stores copay detail and clears loading', () => { + const data = { id: 'monthly-1', attributes: { accountNumber: 'A' } }; + const reducedState = reducer(undefined, { + type: MCP_MONTHLY_STATEMENT_FETCH_SUCCESS, + response: { data }, + }); + expect(reducedState.monthlyStatementCopay).to.deep.equal(data); + expect(reducedState.isMonthlyStatementLoading).to.be.false; + expect(reducedState.monthlyStatementError).to.be.null; + }); + + it('MCP_MONTHLY_STATEMENT_FETCH_FAILURE clears loading and stores error', () => { + const loading = reducer(undefined, { + type: MCP_MONTHLY_STATEMENT_FETCH_INIT, + }); + const err = { title: 'Bad', detail: 'x' }; + const after = reducer(loading, { + type: MCP_MONTHLY_STATEMENT_FETCH_FAILURE, + error: err, + }); + expect(after.isMonthlyStatementLoading).to.be.false; + expect(after.monthlyStatementError).to.deep.equal(err); + }); }); diff --git a/src/applications/combined-debt-portal/medical-copays/tests/unit/statementComponents.unit.spec.jsx b/src/applications/combined-debt-portal/medical-copays/tests/unit/statementComponents.unit.spec.jsx index 6af67e8c8012..419704f2109f 100644 --- a/src/applications/combined-debt-portal/medical-copays/tests/unit/statementComponents.unit.spec.jsx +++ b/src/applications/combined-debt-portal/medical-copays/tests/unit/statementComponents.unit.spec.jsx @@ -3,6 +3,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import { Provider } from 'react-redux'; import { createStore } from 'redux'; +import { I18nextProvider } from 'react-i18next'; +import i18nCombinedDebtPortal from '../../../i18n'; import AccountSummary from '../../components/AccountSummary'; import StatementAddresses from '../../components/StatementAddresses'; import StatementCharges from '../../components/StatementCharges'; @@ -20,12 +22,14 @@ describe('mcp statement view', () => { }; const summary = render( - , + + + , ); - expect(summary.getByTestId('account-summary-head')).to.exist; expect(summary.getByTestId('account-summary-previous')).to.exist; expect(summary.getByTestId('account-summary-previous')).to.have.text( 'Previous balance: $30.00', @@ -106,7 +110,7 @@ describe('mcp statement view', () => { }); }); - describe('statement charges component', () => { + describe('statement charges component (VBS)', () => { it('should render statement charges', () => { const selectedCopay = { details: [ @@ -122,6 +126,25 @@ describe('mcp statement view', () => { expect(charges.getByTestId('statement-charges-head')).to.exist; expect(charges.getByTestId('statement-charges-table')).to.exist; }); + + it('sets table title from copay statement date (VBS shape)', () => { + const selectedCopay = { + pSStatementDateOutput: '05/03/2024', + details: [ + { + pDTransDescOutput: 'Test Charge', + pDRefNo: '123-BILLREF', + pDTransAmtOutput: '100.00', + }, + ], + }; + + const { container } = render(); + const table = container.querySelector('va-table'); + expect(table.getAttribute('table-title')).to.equal( + 'This statement shows charges you received between April 3, 2024 and May 2, 2024.', + ); + }); }); describe('DownloadStatement component', () => { diff --git a/src/applications/combined-debt-portal/medical-copays/tests/unit/statementTable.unit.spec.jsx b/src/applications/combined-debt-portal/medical-copays/tests/unit/statementTable.unit.spec.jsx index b181f47e5642..0d98938c18a1 100644 --- a/src/applications/combined-debt-portal/medical-copays/tests/unit/statementTable.unit.spec.jsx +++ b/src/applications/combined-debt-portal/medical-copays/tests/unit/statementTable.unit.spec.jsx @@ -98,7 +98,7 @@ describe('StatementTable component', () => { .to.exist; }); - it('renders charge date from ISO datePosted via formatISODateToMMDDYYYY', () => { + it('renders charge date from datePosted using formatDate', () => { const lineItems = createLighthouseLineItems(1); lineItems[0].datePosted = '2024-05-15'; lineItems[0].description = 'VHA charge'; diff --git a/src/applications/combined-debt-portal/medical-copays/utils/monthlyStatementAttributes.js b/src/applications/combined-debt-portal/medical-copays/utils/monthlyStatementAttributes.js new file mode 100644 index 000000000000..e772392a6ce5 --- /dev/null +++ b/src/applications/combined-debt-portal/medical-copays/utils/monthlyStatementAttributes.js @@ -0,0 +1,112 @@ +import { + formatDate, + firstDayOfFollowingMonthFromCopayDate, +} from '../../combined/utils/helpers'; + +/** + * Title + DATE for the monthly statement: first calendar day of the month *after* + * the copay’s calendar month (e.g. Feb 22 copay → March 1 heading / MM/dd/yyyy). + * Same rule as previous-statement links (`selectors`: VBS + Lighthouse). + */ +export const statementTitleAndDateFields = (rawCopayDateStr = '') => { + const label = firstDayOfFollowingMonthFromCopayDate(rawCopayDateStr); + const titleLabel = formatDate(label) || label; + return { + titleLabel, + dateField: firstDayOfFollowingMonthFromCopayDate( + rawCopayDateStr, + 'MM/dd/yyyy', + ), + }; +}; + +const prevPageLabel = facilityName => `Copay for ${facilityName}`; +const statementTitle = dateLabel => `${dateLabel} statement`; + +export const buildLegacyStatementAttributes = ({ copays, statementId }) => { + const primary = copays?.[0] ?? null; + const facilityName = primary?.station?.facilityName ?? ''; + const { titleLabel, dateField } = statementTitleAndDateFields( + primary?.pSStatementDateOutput ?? '', + ); + + const downloadReferences = copays.map(copay => ({ + id: copay.id, + reference: copay.details?.[0]?.pDRefNo, + date: copay.pSStatementDateOutput, + })); + + const statementCharges = copays.flatMap( + copay => + copay?.details?.filter( + charge => !charge.pDTransDescOutput.startsWith(' '), + ) ?? [], + ); + + const oldestCopay = copays?.length > 0 ? copays[copays.length - 1] : null; + const previousBalance = oldestCopay?.pHPrevBal; + + const paymentsReceived = copays.reduce( + (sum, copay) => sum + (copay.pHTotCharges || 0), + 0, + ); + + return { + LATEST_COPAY: { + ...primary, + statementId: primary?.statement_id ?? statementId, + }, + TITLE: statementTitle(titleLabel || ''), + DATE: dateField, + PREV_PAGE: prevPageLabel(facilityName), + FACILITY_NAME: facilityName, + ACCOUNT_NUMBER: primary?.accountNumber || '', + CHARGES: statementCharges, + PREVIOUS_BALANCE: previousBalance, + PAYMENTS_RECEIVED: paymentsReceived, + DOWNLOAD_REFERENCES: downloadReferences, + }; +}; + +export const buildLighthouseStatementAttributes = ({ + monthlyStatement, + monthlyStatementCopay, + allCopays, +}) => { + const { + principalPaid: paymentsReceived = 0, + facility: { name: facilityName } = {}, + accountNumber = '', + } = monthlyStatementCopay?.attributes ?? {}; + + const oldestCopayInStatement = monthlyStatement?.copays?.at(-1)?.id; + const oldestCopayWithAttributes = allCopays?.find( + c => c.id === oldestCopayInStatement, + ); + const previousUnpaidBalance = + oldestCopayWithAttributes?.attributes?.previousUnpaidBalance; + + const statementCopays = monthlyStatement?.copays ?? []; + const downloadReferences = statementCopays.map(copay => ({ + id: copay.id, + reference: copay.billNumber, + date: copay.date, + })); + const statementCharges = statementCopays.flatMap(copay => copay.lineItems); + const { titleLabel, dateField } = statementTitleAndDateFields( + monthlyStatement?.copays?.[0]?.date, + ); + + return { + LATEST_COPAY: monthlyStatementCopay, + TITLE: statementTitle(titleLabel || ''), + DATE: dateField, + PREV_PAGE: prevPageLabel(facilityName), + FACILITY_NAME: facilityName, + ACCOUNT_NUMBER: accountNumber, + CHARGES: statementCharges, + PREVIOUS_BALANCE: previousUnpaidBalance, + PAYMENTS_RECEIVED: paymentsReceived, + DOWNLOAD_REFERENCES: downloadReferences, + }; +};