From 7aacb2bd59838a27d61d7590ba603504c77f10e8 Mon Sep 17 00:00:00 2001 From: Michael Walsh Date: Fri, 22 May 2026 14:48:56 -0700 Subject: [PATCH 1/3] DC-472 Update: Send co-loaded only users to appropriate offbaording screen --- .../Household/CoLoadedCohortClassifier.cs | 49 +++++++++ .../Services/DatabaseSeeder.cs | 32 ++++-- .../GetHouseholdDataQueryHandler.cs | 42 +------- .../SubmitIdProofingCommandHandler.cs | 72 +++++++++++--- .../id-proofing/off-boarding/page.test.tsx | 9 ++ .../login/id-proofing/off-boarding/page.tsx | 12 +-- .../components/id-proofing/IdProofingForm.tsx | 9 +- .../CoLoadedCohortClassifierTests.cs | 99 +++++++++++++++++++ .../Unit/Services/DatabaseSeederTests.cs | 2 +- .../SubmitIdProofingCommandHandlerTests.cs | 93 +++++++++++++++++ 10 files changed, 350 insertions(+), 69 deletions(-) create mode 100644 src/SEBT.Portal.Core/Models/Household/CoLoadedCohortClassifier.cs create mode 100644 test/SEBT.Portal.Tests/Unit/Models/Household/CoLoadedCohortClassifierTests.cs diff --git a/src/SEBT.Portal.Core/Models/Household/CoLoadedCohortClassifier.cs b/src/SEBT.Portal.Core/Models/Household/CoLoadedCohortClassifier.cs new file mode 100644 index 000000000..0fd7f5c35 --- /dev/null +++ b/src/SEBT.Portal.Core/Models/Household/CoLoadedCohortClassifier.cs @@ -0,0 +1,49 @@ +namespace SEBT.Portal.Core.Models.Household; + +/// +/// Derives from pre-filter household case and application state. +/// Shared by household reads and ID proofing off-boarding decisions. +/// +public static class CoLoadedCohortClassifier +{ + /// + /// Classifies the household based on its case list and applications. + /// See for the rule. + /// + public static CoLoadedCohort Classify(HouseholdData? household) + { + if (household == null) + { + return CoLoadedCohort.NonCoLoaded; + } + + var hasCoLoaded = household.SummerEbtCases.Any(c => c.IsCoLoaded); + if (!hasCoLoaded) + { + return CoLoadedCohort.NonCoLoaded; + } + + var hasNonCoLoaded = household.SummerEbtCases.Any(c => !c.IsCoLoaded); + var hasInFlightHouseholdApplication = household.Applications.Any(IsInFlightHouseholdApplication); + var hasPendingCase = household.SummerEbtCases.Any(IsPendingApplicant); + + return hasNonCoLoaded || hasInFlightHouseholdApplication || hasPendingCase + ? CoLoadedCohort.MixedOrApplicantExcluded + : CoLoadedCohort.CoLoadedOnly; + } + + /// + /// Maps a default off-boarding reason to the co-loaded-only screen when the household + /// cohort warrants it. + /// + public static string ResolveOffboardingReason(string defaultReason, HouseholdData? household) => + Classify(household) == CoLoadedCohort.CoLoadedOnly + ? "coLoadedOnly" + : defaultReason; + + private static bool IsPendingApplicant(SummerEbtCase summerEbtCase) => + summerEbtCase.ApplicationStatus is ApplicationStatus.Pending or ApplicationStatus.UnderReview; + + private static bool IsInFlightHouseholdApplication(Application application) => + application.ApplicationStatus is ApplicationStatus.Pending or ApplicationStatus.UnderReview; +} diff --git a/src/SEBT.Portal.Infrastructure.Seeding/Services/DatabaseSeeder.cs b/src/SEBT.Portal.Infrastructure.Seeding/Services/DatabaseSeeder.cs index 44b02e9b7..20ed43350 100644 --- a/src/SEBT.Portal.Infrastructure.Seeding/Services/DatabaseSeeder.cs +++ b/src/SEBT.Portal.Infrastructure.Seeding/Services/DatabaseSeeder.cs @@ -66,7 +66,7 @@ public async Task SeedUsersAsync(int userCount = 10, CancellationToken cancellat /// An array of User instances configured for testing. private User[] CreateTestUsers(DateTime now) { - return new[] + var users = new List { UserFactory.CreateCoLoadedUser(u => { @@ -80,6 +80,26 @@ private User[] CreateTestUsers(DateTime now) u.TanfId = "TANF-CO-001"; u.Ssn = "123456789"; }), + }; + + if (IsDc) + { + users.Add(UserFactory.CreateNonCoLoadedUser(u => + { + u.Email = _settings.BuildEmail(SeedScenarios.CoLoadedPendingIdProofing.Name); + u.IdProofingStatus = IdProofingStatus.NotStarted; + u.IalLevel = UserIalLevel.None; + u.IdProofingCompletedAt = null; + u.IdProofingExpiresAt = null; + u.Phone = "8185558438"; + u.SnapId = "SNAP-CO-001"; + u.TanfId = "TANF-CO-001"; + u.Ssn = "123456789"; + })); + } + + users.AddRange( + [ UserFactory.CreateNonCoLoadedUser(u => { u.Email = _settings.BuildEmail(SeedScenarios.NonCoLoaded.Name); @@ -98,7 +118,9 @@ private User[] CreateTestUsers(DateTime now) u.IdProofingCompletedAt = null; u.IdProofingExpiresAt = null; }) - }; + ]); + + return users.ToArray(); } /// @@ -170,14 +192,13 @@ public async Task SeedTestUsersAsync(bool useMockHouseholdData, CancellationToke } else if (normalizedEmail == coLoadedPendingIdProofingEmail) { - user = UserFactory.CreateCoLoadedUser(u => + user = UserFactory.CreateNonCoLoadedUser(u => { u.Email = normalizedEmail; u.IdProofingStatus = IdProofingStatus.NotStarted; u.IalLevel = UserIalLevel.None; u.IdProofingCompletedAt = null; u.IdProofingExpiresAt = null; - u.CoLoadedLastUpdated = now.AddDays(DaysSinceCoLoadedUpdate); u.Phone = "8185558438"; u.SnapId = "SNAP-CO-001"; u.TanfId = "TANF-CO-001"; @@ -370,14 +391,13 @@ public void SeedTestUsers(bool useMockHouseholdData) } else if (normalizedEmail == coLoadedPendingIdProofingEmail) { - user = UserFactory.CreateCoLoadedUser(u => + user = UserFactory.CreateNonCoLoadedUser(u => { u.Email = normalizedEmail; u.IdProofingStatus = IdProofingStatus.NotStarted; u.IalLevel = UserIalLevel.None; u.IdProofingCompletedAt = null; u.IdProofingExpiresAt = null; - u.CoLoadedLastUpdated = now.AddDays(DaysSinceCoLoadedUpdate); u.Phone = "8185558438"; u.SnapId = "SNAP-CO-001"; u.TanfId = "TANF-CO-001"; diff --git a/src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs b/src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs index e23f0bfb8..8b01a7dfe 100644 --- a/src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs +++ b/src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs @@ -111,7 +111,7 @@ public async Task> Handle(GetHouseholdDataQuery query, Can // Classify the household on the PRE-filter state so analytics can // distinguish cohorts even after co-loaded cases are suppressed. Then // apply the suppression for the excluded cohort. - householdData.CoLoadedCohort = ClassifyCoLoadedCohort(householdData); + householdData.CoLoadedCohort = CoLoadedCohortClassifier.Classify(householdData); var nonCoLoaded = householdData.SummerEbtCases.Where(c => !c.IsCoLoaded).ToList(); if (coLoadedCohortFilter.SuppressCoLoadedCasesForExcludedCohort @@ -164,44 +164,4 @@ public async Task> Handle(GetHouseholdDataQuery query, Can return Result.Success(householdData); } - /// - /// Classifies the household based on its pre-filter case list and applications. - /// See for the rule. - /// The rule is intentionally derived at runtime from case and application state; changing - /// who falls into each cohort still requires a code change. Whether co-loaded cases are - /// suppressed for the excluded cohort is configured via - /// . - /// - private static CoLoadedCohort ClassifyCoLoadedCohort(HouseholdData household) - { - var hasCoLoaded = household.SummerEbtCases.Any(c => c.IsCoLoaded); - if (!hasCoLoaded) - { - return CoLoadedCohort.NonCoLoaded; - } - - var hasNonCoLoaded = household.SummerEbtCases.Any(c => !c.IsCoLoaded); - var hasInFlightHouseholdApplication = household.Applications.Any(IsInFlightHouseholdApplication); - var hasPendingCase = household.SummerEbtCases.Any(IsPendingApplicant); - - return hasNonCoLoaded || hasInFlightHouseholdApplication || hasPendingCase - ? CoLoadedCohort.MixedOrApplicantExcluded - : CoLoadedCohort.CoLoadedOnly; - } - - /// - /// A case whose application hasn't been adjudicated yet represents an - /// in-flight applicant experience, which places the household in the - /// applicant-excluded cohort even when the case itself is co-loaded. - /// - private static bool IsPendingApplicant(SummerEbtCase summerEbtCase) => - summerEbtCase.ApplicationStatus is ApplicationStatus.Pending or ApplicationStatus.UnderReview; - - /// - /// Household-level often retains historical rows - /// (approved/denied/cancelled). Only pending or under-review applications indicate an active - /// applicant journey alongside co-loaded cases. - /// - private static bool IsInFlightHouseholdApplication(Application application) => - application.ApplicationStatus is ApplicationStatus.Pending or ApplicationStatus.UnderReview; } diff --git a/src/SEBT.Portal.UseCases/IdProofing/SubmitIdProofing/SubmitIdProofingCommandHandler.cs b/src/SEBT.Portal.UseCases/IdProofing/SubmitIdProofing/SubmitIdProofingCommandHandler.cs index 4241b2251..d89ed27b0 100644 --- a/src/SEBT.Portal.UseCases/IdProofing/SubmitIdProofing/SubmitIdProofingCommandHandler.cs +++ b/src/SEBT.Portal.UseCases/IdProofing/SubmitIdProofing/SubmitIdProofingCommandHandler.cs @@ -95,6 +95,10 @@ public async Task> Handle( PreconditionFailedReason.Conflict, "Email is required for ID proofing."); } + var warehouseIalForEmailReads = PreSocureHouseholdWarehouseIal.ForEmailLinkedHouseholdRead( + user.IalLevel, + idProofingEligibilitySettings.Value.RequireQualifyingHouseholdForSocure); + // Max attempts reached → off-board (3-attempt cap) const int maxAttempts = 3; if (user.IdProofingAttemptCount >= maxAttempts) @@ -102,25 +106,43 @@ public async Task> Handle( logger.LogInformation( "User {UserId} has reached the maximum ID proofing attempts ({MaxAttempts})", command.UserId, maxAttempts); + var householdForMaxAttempts = await TryGetHouseholdByEmailAsync( + user, + warehouseIalForEmailReads, + command.UserId, + cancellationToken); return Result.Success( new SubmitIdProofingResponse("failed", AllowIdRetry: false, - OffboardingReason: "maxAttemptsReached")); + OffboardingReason: CoLoadedCohortClassifier.ResolveOffboardingReason( + "maxAttemptsReached", + householdForMaxAttempts))); } - // Co-loaded users still need a SNAP/TANF identifier so we can household them; off-board - // when no ID is provided. Non-co-loaded users fall through to Socure DocV — Socure's + // Co-loaded-only households still need a SNAP/TANF identifier so we can household them; + // off-board when no ID is provided. Other users fall through to Socure DocV — Socure's // consumer_onboarding workflow short-circuits to document verification when KYC can't // resolve the consumer, so national_id is optional for that path. if (string.IsNullOrWhiteSpace(command.IdType)) { - if (user.IsCoLoaded) + var householdForNoId = await TryGetHouseholdByEmailAsync( + user, + warehouseIalForEmailReads, + command.UserId, + cancellationToken); + var coLoadedOnlyCohort = CoLoadedCohortClassifier.Classify(householdForNoId) == CoLoadedCohort.CoLoadedOnly; + + if (user.IsCoLoaded || coLoadedOnlyCohort) { logger.LogInformation( - "Co-loaded user {UserId} submitted ID proofing without an ID type; off-boarding (householding requires a benefit ID)", + "User {UserId} submitted ID proofing without an ID type; off-boarding (householding requires a benefit ID)", command.UserId); return Result.Success( - new SubmitIdProofingResponse("failed", OffboardingReason: "noIdProvided")); + new SubmitIdProofingResponse( + "failed", + OffboardingReason: CoLoadedCohortClassifier.ResolveOffboardingReason( + "noIdProvided", + householdForNoId))); } logger.LogInformation( @@ -146,10 +168,6 @@ public async Task> Handle( // Persist the parsed DOB on the user; all downstream save paths will carry it through. user.DateOfBirth = submittedDob; - var warehouseIalForEmailReads = PreSocureHouseholdWarehouseIal.ForEmailLinkedHouseholdRead( - user.IalLevel, - idProofingEligibilitySettings.Value.RequireQualifyingHouseholdForSocure); - // Co-loaded discovery: SNAP/TANF ids are an in-portal lookup (never Socure as national_id). // User-level IsCoLoaded isn't presumed from a pre-populated flag — the match itself is the // determination, and on success we persist it so downstream UI flows can rely on the claim. @@ -228,7 +246,9 @@ public async Task> Handle( new SubmitIdProofingResponse( "failed", AllowIdRetry: allowBenefitRetry, - OffboardingReason: "idProofingFailed")); + OffboardingReason: CoLoadedCohortClassifier.ResolveOffboardingReason( + "idProofingFailed", + benefitHousehold))); } // Fetch household data for Socure: state/CMS may supply name, address, and phone when available. @@ -353,7 +373,9 @@ public async Task> Handle( new SubmitIdProofingResponse( "failed", AllowIdRetry: allowIdRetry, - OffboardingReason: "idProofingFailed")); + OffboardingReason: CoLoadedCohortClassifier.ResolveOffboardingReason( + "idProofingFailed", + householdForSocure))); case IdProofingOutcome.DocumentVerificationRequired: await userRepository.UpdateUserAsync(user, cancellationToken); @@ -479,4 +501,30 @@ private static bool IsExactlyNineDigitsAfterStripping(string? idValue) } return digitCount == 9; } + + private async Task TryGetHouseholdByEmailAsync( + User user, + UserIalLevel warehouseIalForEmailReads, + Guid portalUserId, + CancellationToken cancellationToken) + { + try + { + return await householdRepository.GetHouseholdByEmailAsync( + user.Email!, + new PiiVisibility(IncludeAddress: false, IncludeEmail: false, IncludePhone: false), + warehouseIalForEmailReads, + portalUserId, + cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError( + ex, + "Household lookup failed ({ExceptionType}) for user {UserId} during ID proofing cohort check", + ex.GetType().Name, + portalUserId); + return null; + } + } } diff --git a/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.test.tsx b/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.test.tsx index 3fc20a123..9f13f9182 100644 --- a/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.test.tsx +++ b/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.test.tsx @@ -209,6 +209,15 @@ describe('OffBoardingPage', () => { expect(content).toHaveAttribute('data-apply-body', 'offBoarding:coLoadedBody2') expect(content).toHaveAttribute('data-apply-label', 'offBoarding:coLoadedAction2') }) + + it('uses the co-loaded off-boarding copy when reason is coLoadedOnly', () => { + renderPage({ isCoLoaded: false, reason: 'coLoadedOnly' }) + + const content = screen.getByTestId('off-boarding-content') + expect(content).toHaveAttribute('data-title', 'offBoarding:coLoadedTitle') + expect(content).toHaveAttribute('data-body', 'offBoarding:coLoadedBody1') + expect(content).toHaveAttribute('data-contact-label', 'offBoarding:coLoadedAction1') + }) }) describe('Static props', () => { diff --git a/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.tsx b/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.tsx index e3d561921..70a6e8b85 100644 --- a/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.tsx +++ b/src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/off-boarding/page.tsx @@ -14,6 +14,7 @@ export default function OffBoardingPage() { const { session } = useAuth() const isCoLoaded = session?.isCoLoaded === true + const useCoLoadedOffboarding = isCoLoaded || reason === 'coLoadedOnly' const { t, i18n } = useTranslation('offBoarding') const { t: tDashboard } = useTranslation('dashboard') @@ -28,12 +29,9 @@ export default function OffBoardingPage() { const contactHref = links.help.contactUs !== '#' ? links.help.contactUs : (links.help.helpDeskEmail ?? '#') - // Branch order: OIDC `/callback` failures, then co-loaded copy, - // then reason-specific copy for the non-co-loaded path, then generic offBoarding copy. - // - Co-loaded users cannot off-board to Socure DocV per PRD; they see a - // "cannot identify you" screen instead of the DocV-flavored copy. - // - Reason-specific branches force canApply=false until product decides - // which failure modes allow re-application. + // Branch order: OIDC `/callback` failures, then co-loaded copy (session flag or + // coLoadedOnly reason from household cohort lookup during ID proofing), then + // reason-specific copy for the non-co-loaded path, then generic offBoarding copy. // TODO: Replace hardcoded strings with t(...) keys once they exist in dc.csv. let title: string let body: string @@ -54,7 +52,7 @@ export default function OffBoardingPage() { applyBody = undefined applySkipBody = undefined applyLabel = undefined - } else if (isCoLoaded) { + } else if (useCoLoadedOffboarding) { title = t('coLoadedTitle') body = t('coLoadedBody1') contactLabel = t('coLoadedAction1') diff --git a/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/IdProofingForm.tsx b/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/IdProofingForm.tsx index 3395993f1..eabeeed40 100644 --- a/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/IdProofingForm.tsx +++ b/src/SEBT.Portal.Web/src/features/auth/components/id-proofing/IdProofingForm.tsx @@ -249,8 +249,13 @@ export function IdProofingForm({ idOptions, contactLink, getDiToken }: IdProofin setPageData('idv_primary_reason', 'no_qualifying_household') } else { // Co-loaded users reach "failed" only via SNAP/TANF + DOB mismatch (no Socure), - // so their failure is always a not-found. Non-co-loaded failures come from Socure. - setPageData('idv_primary_reason', isCoLoaded ? 'not_found' : 'socure_fail') + // or when the backend classified the household as co-loaded-only. + setPageData( + 'idv_primary_reason', + isCoLoaded || response.offboardingReason === 'coLoadedOnly' + ? 'not_found' + : 'socure_fail' + ) } trackEvent(AnalyticsEvents.IDV_PRIMARY_RESULT) // Hand off offboarding context via URL query params so the server-rendered diff --git a/test/SEBT.Portal.Tests/Unit/Models/Household/CoLoadedCohortClassifierTests.cs b/test/SEBT.Portal.Tests/Unit/Models/Household/CoLoadedCohortClassifierTests.cs new file mode 100644 index 000000000..519801dec --- /dev/null +++ b/test/SEBT.Portal.Tests/Unit/Models/Household/CoLoadedCohortClassifierTests.cs @@ -0,0 +1,99 @@ +using SEBT.Portal.Core.Models.Household; + +namespace SEBT.Portal.Tests.Unit.Models.Household; + +public class CoLoadedCohortClassifierTests +{ + [Fact] + public void Classify_ReturnsNonCoLoaded_WhenHouseholdIsNull() + { + Assert.Equal(CoLoadedCohort.NonCoLoaded, CoLoadedCohortClassifier.Classify(null)); + } + + [Fact] + public void Classify_ReturnsCoLoadedOnly_WhenAllCasesAreCoLoadedAndNoApplications() + { + var household = new HouseholdData + { + SummerEbtCases = + [ + new SummerEbtCase + { + SummerEBTCaseID = "S1", + ChildFirstName = "A", + ChildLastName = "B", + IsCoLoaded = true + } + ] + }; + + Assert.Equal(CoLoadedCohort.CoLoadedOnly, CoLoadedCohortClassifier.Classify(household)); + } + + [Fact] + public void Classify_ReturnsMixedOrApplicantExcluded_WhenNonCoLoadedCaseExists() + { + var household = new HouseholdData + { + SummerEbtCases = + [ + new SummerEbtCase + { + SummerEBTCaseID = "S1", + ChildFirstName = "A", + ChildLastName = "B", + IsCoLoaded = true + }, + new SummerEbtCase + { + SummerEBTCaseID = "S2", + ChildFirstName = "C", + ChildLastName = "D", + IsCoLoaded = false + } + ] + }; + + Assert.Equal(CoLoadedCohort.MixedOrApplicantExcluded, CoLoadedCohortClassifier.Classify(household)); + } + + [Fact] + public void ResolveOffboardingReason_ReturnsCoLoadedOnly_WhenHouseholdIsCoLoadedOnly() + { + var household = new HouseholdData + { + SummerEbtCases = + [ + new SummerEbtCase + { + SummerEBTCaseID = "S1", + ChildFirstName = "A", + ChildLastName = "B", + IsCoLoaded = true + } + ] + }; + + Assert.Equal("coLoadedOnly", CoLoadedCohortClassifier.ResolveOffboardingReason("idProofingFailed", household)); + } + + [Fact] + public void ResolveOffboardingReason_ReturnsDefaultReason_WhenHouseholdIsNotCoLoadedOnly() + { + var household = new HouseholdData + { + SummerEbtCases = + [ + new SummerEbtCase + { + SummerEBTCaseID = "S1", + ChildFirstName = "A", + ChildLastName = "B", + IsCoLoaded = false + } + ] + }; + + Assert.Equal("idProofingFailed", CoLoadedCohortClassifier.ResolveOffboardingReason("idProofingFailed", household)); + } +} diff --git a/test/SEBT.Portal.Tests/Unit/Services/DatabaseSeederTests.cs b/test/SEBT.Portal.Tests/Unit/Services/DatabaseSeederTests.cs index 8d008c8b5..8c66809a9 100644 --- a/test/SEBT.Portal.Tests/Unit/Services/DatabaseSeederTests.cs +++ b/test/SEBT.Portal.Tests/Unit/Services/DatabaseSeederTests.cs @@ -726,7 +726,7 @@ public async Task SeedTestUsersAsync_WithMockHouseholdData_AndStateDc_ShouldSeed var pending = await context.Users .SingleOrDefaultAsync(u => u.Email == "co-loaded-pending-id-proofing@example.com"); Assert.NotNull(pending); - Assert.True(pending!.IsCoLoaded); + Assert.False(pending!.IsCoLoaded); Assert.Equal((int)IdProofingStatus.NotStarted, pending.IdProofingStatus); Assert.Equal((int)UserIalLevel.None, pending.IalLevel); Assert.Null(pending.IdProofingCompletedAt); diff --git a/test/SEBT.Portal.Tests/Unit/UseCases/IdProofing/SubmitIdProofingCommandHandlerTests.cs b/test/SEBT.Portal.Tests/Unit/UseCases/IdProofing/SubmitIdProofingCommandHandlerTests.cs index d5407b6a1..7a68c6938 100644 --- a/test/SEBT.Portal.Tests/Unit/UseCases/IdProofing/SubmitIdProofingCommandHandlerTests.cs +++ b/test/SEBT.Portal.Tests/Unit/UseCases/IdProofing/SubmitIdProofingCommandHandlerTests.cs @@ -404,6 +404,99 @@ await socureClient.DidNotReceive() Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task Handle_ShouldReturnCoLoadedOnlyOffboarding_WhenBenefitIdDoesNotMatchAndHouseholdIsCoLoadedOnly() + { + var handler = CreateHandler(); + var command = CreateValidCommand(idType: "snapAccountId", idValue: "wrong-id"); + var user = new User + { + Id = command.UserId, + Email = "test@example.com", + IsCoLoaded = false, + IdProofingAttemptCount = 0 + }; + + userRepository.GetUserByIdAsync(command.UserId, Arg.Any()) + .Returns(user); + challengeRepository.GetActiveByUserIdAsync(command.UserId, Arg.Any()) + .Returns((DocVerificationChallenge?)null); + householdRepository.TryMatchCoLoadedGuardianByBenefitIdAndDobAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + householdRepository.GetHouseholdByEmailAsync( + user.Email, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new HouseholdData + { + SummerEbtCases = + [ + new SummerEbtCase + { + SummerEBTCaseID = "S1", + ChildFirstName = "A", + ChildLastName = "B", + IsCoLoaded = true + } + ] + }); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("failed", result.Value.Result); + Assert.Equal("coLoadedOnly", result.Value.OffboardingReason); + } + + [Fact] + public async Task Handle_ShouldReturnCoLoadedOnlyOffboarding_WhenNoIdProvidedAndHouseholdIsCoLoadedOnly() + { + var handler = CreateHandler(); + var command = CreateValidCommand(idType: null, idValue: null); + var user = new User + { + Id = command.UserId, + Email = "test@example.com", + IsCoLoaded = false + }; + + userRepository.GetUserByIdAsync(command.UserId, Arg.Any()) + .Returns(user); + householdRepository.GetHouseholdByEmailAsync( + user.Email, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new HouseholdData + { + SummerEbtCases = + [ + new SummerEbtCase + { + SummerEBTCaseID = "S1", + ChildFirstName = "A", + ChildLastName = "B", + IsCoLoaded = true + } + ] + }); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("failed", result.Value.Result); + Assert.Equal("coLoadedOnly", result.Value.OffboardingReason); + + await socureClient.DidNotReceive() + .RunIdProofingAssessmentAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task Handle_ShouldPersistDateOfBirth_WhenSubmittedDobIsParseable() { From 3f60bd6fb2e5991b23cbd0599f50dbfb7bf9e382 Mon Sep 17 00:00:00 2001 From: Michael Walsh Date: Fri, 22 May 2026 15:57:44 -0700 Subject: [PATCH 2/3] DC-472 Update: PrcessWebhookCommand --- .../ProcessWebhookCommandHandler.cs | 61 ++++++++++++- .../ProcessWebhookCommandHandlerTests.cs | 86 ++++++++++++++++++- 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/src/SEBT.Portal.UseCases/IdProofing/ProcessWebhook/ProcessWebhookCommandHandler.cs b/src/SEBT.Portal.UseCases/IdProofing/ProcessWebhook/ProcessWebhookCommandHandler.cs index f68120e55..be5c80428 100644 --- a/src/SEBT.Portal.UseCases/IdProofing/ProcessWebhook/ProcessWebhookCommandHandler.cs +++ b/src/SEBT.Portal.UseCases/IdProofing/ProcessWebhook/ProcessWebhookCommandHandler.cs @@ -1,11 +1,15 @@ using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using SEBT.Portal.Core.AppSettings; using SEBT.Portal.Core.Exceptions; +using SEBT.Portal.Core.Models; using SEBT.Portal.Core.Models.Auth; using SEBT.Portal.Core.Models.DocVerification; +using SEBT.Portal.Core.Models.Household; using SEBT.Portal.Core.Repositories; +using SEBT.Portal.Core.Utilities; using SEBT.Portal.Kernel; using SEBT.Portal.Kernel.Results; @@ -21,7 +25,9 @@ namespace SEBT.Portal.UseCases.IdProofing; public class ProcessWebhookCommandHandler( IDocVerificationChallengeRepository challengeRepository, IUserRepository userRepository, + IHouseholdRepository householdRepository, SocureSettings socureSettings, + IOptions idProofingEligibilitySettings, IValidator validator, ILogger logger) : ICommandHandler @@ -125,7 +131,9 @@ public async Task Handle( if (newStatus == DocVerificationStatus.Rejected) { - challenge.OffboardingReason = "docVerificationFailed"; + challenge.OffboardingReason = await ResolveRejectionOffboardingReasonAsync( + challenge.UserId, + cancellationToken); } try @@ -223,6 +231,57 @@ private bool ValidateWebhookSignature(string? bearerToken) }; } + private async Task ResolveRejectionOffboardingReasonAsync( + Guid userId, + CancellationToken cancellationToken) + { + const string defaultReason = "docVerificationFailed"; + + var user = await userRepository.GetUserByIdAsync(userId, cancellationToken); + if (user == null || string.IsNullOrWhiteSpace(user.Email)) + { + return defaultReason; + } + + var warehouseIal = PreSocureHouseholdWarehouseIal.ForEmailLinkedHouseholdRead( + user.IalLevel, + idProofingEligibilitySettings.Value.RequireQualifyingHouseholdForSocure); + + var household = await TryGetHouseholdByEmailAsync( + user, + warehouseIal, + userId, + cancellationToken); + + return CoLoadedCohortClassifier.ResolveOffboardingReason(defaultReason, household); + } + + private async Task TryGetHouseholdByEmailAsync( + User user, + UserIalLevel warehouseIalForEmailReads, + Guid portalUserId, + CancellationToken cancellationToken) + { + try + { + return await householdRepository.GetHouseholdByEmailAsync( + user.Email!, + new PiiVisibility(IncludeAddress: false, IncludeEmail: false, IncludePhone: false), + warehouseIalForEmailReads, + portalUserId, + cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError( + ex, + "Household lookup failed ({ExceptionType}) for user {UserId} during DocV rejection cohort check", + ex.GetType().Name, + portalUserId); + return null; + } + } + private async Task UpdateUserProofingStatus(Guid userId, CancellationToken cancellationToken) { var user = await userRepository.GetUserByIdAsync(userId, cancellationToken); diff --git a/test/SEBT.Portal.Tests/Unit/UseCases/IdProofing/ProcessWebhookCommandHandlerTests.cs b/test/SEBT.Portal.Tests/Unit/UseCases/IdProofing/ProcessWebhookCommandHandlerTests.cs index 88f717970..2234bc930 100644 --- a/test/SEBT.Portal.Tests/Unit/UseCases/IdProofing/ProcessWebhookCommandHandlerTests.cs +++ b/test/SEBT.Portal.Tests/Unit/UseCases/IdProofing/ProcessWebhookCommandHandlerTests.cs @@ -1,10 +1,13 @@ using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; using SEBT.Portal.Core.AppSettings; using SEBT.Portal.Core.Exceptions; +using SEBT.Portal.Core.Models; using SEBT.Portal.Core.Models.Auth; using SEBT.Portal.Core.Models.DocVerification; +using SEBT.Portal.Core.Models.Household; using SEBT.Portal.Core.Repositories; using SEBT.Portal.Kernel; using SEBT.Portal.Kernel.Results; @@ -18,14 +21,24 @@ public class ProcessWebhookCommandHandlerTests private readonly IDocVerificationChallengeRepository challengeRepository = Substitute.For(); private readonly IUserRepository userRepository = Substitute.For(); + private readonly IHouseholdRepository householdRepository = Substitute.For(); private readonly SocureSettings socureSettings = new() { UseStub = true }; + private readonly IOptions idProofingEligibilitySettings = + Options.Create(new IdProofingEligibilitySettings { RequireQualifyingHouseholdForSocure = true }); private readonly IValidator validator = new DataAnnotationsValidator(null!); private readonly NullLogger logger = NullLogger.Instance; private ProcessWebhookCommandHandler CreateHandler() => - new(challengeRepository, userRepository, socureSettings, validator, logger); + new( + challengeRepository, + userRepository, + householdRepository, + socureSettings, + idProofingEligibilitySettings, + validator, + logger); private static ProcessWebhookCommand CreateValidCommand( string eventId = "evt-123", @@ -49,7 +62,13 @@ public async Task Handle_ShouldRejectWebhook_WhenSignatureInvalid_InNonStubMode( { var settings = new SocureSettings { UseStub = false, WebhookSecret = "secret" }; var handler = new ProcessWebhookCommandHandler( - challengeRepository, userRepository, settings, validator, logger); + challengeRepository, + userRepository, + householdRepository, + settings, + idProofingEligibilitySettings, + validator, + logger); var command = new ProcessWebhookCommand { @@ -76,7 +95,13 @@ public async Task Handle_ShouldAcceptWebhook_WhenBearerTokenMatchesSecret() { var settings = new SocureSettings { UseStub = false, WebhookSecret = "my-webhook-secret" }; var handler = new ProcessWebhookCommandHandler( - challengeRepository, userRepository, settings, validator, logger); + challengeRepository, + userRepository, + householdRepository, + settings, + idProofingEligibilitySettings, + validator, + logger); var challenge = DocVerificationChallengeFactory.CreatePendingChallenge(); var user = new User { Id = challenge.UserId, Email = "test@example.com" }; @@ -105,7 +130,13 @@ public async Task Handle_ShouldRejectWebhook_WhenBearerTokenDoesNotMatchSecret() { var settings = new SocureSettings { UseStub = false, WebhookSecret = "correct-secret" }; var handler = new ProcessWebhookCommandHandler( - challengeRepository, userRepository, settings, validator, logger); + challengeRepository, + userRepository, + householdRepository, + settings, + idProofingEligibilitySettings, + validator, + logger); var command = new ProcessWebhookCommand { @@ -243,6 +274,53 @@ await userRepository.DidNotReceive() .UpdateUserAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task Handle_ShouldReturnCoLoadedOnlyOffboarding_WhenDocVRejectedAndHouseholdIsCoLoadedOnly() + { + var handler = CreateHandler(); + var challenge = DocVerificationChallengeFactory.CreatePendingChallenge(); + var user = new User + { + Id = challenge.UserId, + Email = "test@example.com", + IsCoLoaded = false + }; + + challengeRepository.GetBySocureReferenceIdAsync("ref-456", Arg.Any()) + .Returns(challenge); + userRepository.GetUserByIdAsync(challenge.UserId, Arg.Any()) + .Returns(user); + householdRepository.GetHouseholdByEmailAsync( + user.Email, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new HouseholdData + { + SummerEbtCases = + [ + new SummerEbtCase + { + SummerEBTCaseID = "S1", + ChildFirstName = "A", + ChildLastName = "B", + IsCoLoaded = true + } + ] + }); + + var command = CreateValidCommand(workflowDecision: "REJECT", documentDecision: "reject"); + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + await challengeRepository.Received(1) + .UpdateAsync(Arg.Is(c => + c.Status == DocVerificationStatus.Rejected + && c.OffboardingReason == "coLoadedOnly"), + Arg.Any()); + } + // --- EvalId fallback correlation (D6) --- [Fact] From eb15e74231f97ef49cb1a77082c70928076cb52f Mon Sep 17 00:00:00 2001 From: Michael Walsh Date: Fri, 22 May 2026 16:08:26 -0700 Subject: [PATCH 3/3] DC-472 Update: Extract shared IdProofingHouseholdLookup helper --- .../IdProofing/IdProofingHouseholdLookup.cs | 41 +++++++++++++++++++ .../ProcessWebhookCommandHandler.cs | 30 ++------------ .../SubmitIdProofingCommandHandler.cs | 33 +++------------ 3 files changed, 50 insertions(+), 54 deletions(-) create mode 100644 src/SEBT.Portal.UseCases/IdProofing/IdProofingHouseholdLookup.cs diff --git a/src/SEBT.Portal.UseCases/IdProofing/IdProofingHouseholdLookup.cs b/src/SEBT.Portal.UseCases/IdProofing/IdProofingHouseholdLookup.cs new file mode 100644 index 000000000..ad114fd9e --- /dev/null +++ b/src/SEBT.Portal.UseCases/IdProofing/IdProofingHouseholdLookup.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Logging; +using SEBT.Portal.Core.Models; +using SEBT.Portal.Core.Models.Auth; +using SEBT.Portal.Core.Models.Household; +using SEBT.Portal.Core.Repositories; + +namespace SEBT.Portal.UseCases.IdProofing; + +/// +/// Shared warehouse household reads for ID proofing off-boarding cohort checks. +/// +internal static class IdProofingHouseholdLookup +{ + internal static async Task TryGetByEmailForCohortCheckAsync( + IHouseholdRepository householdRepository, + ILogger logger, + User user, + UserIalLevel warehouseIalForEmailReads, + Guid portalUserId, + CancellationToken cancellationToken) + { + try + { + return await householdRepository.GetHouseholdByEmailAsync( + user.Email!, + new PiiVisibility(IncludeAddress: false, IncludeEmail: false, IncludePhone: false), + warehouseIalForEmailReads, + portalUserId, + cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError( + ex, + "Household lookup failed ({ExceptionType}) for user {UserId} during off-boarding cohort check", + ex.GetType().Name, + portalUserId); + return null; + } + } +} diff --git a/src/SEBT.Portal.UseCases/IdProofing/ProcessWebhook/ProcessWebhookCommandHandler.cs b/src/SEBT.Portal.UseCases/IdProofing/ProcessWebhook/ProcessWebhookCommandHandler.cs index be5c80428..2c0416c0c 100644 --- a/src/SEBT.Portal.UseCases/IdProofing/ProcessWebhook/ProcessWebhookCommandHandler.cs +++ b/src/SEBT.Portal.UseCases/IdProofing/ProcessWebhook/ProcessWebhookCommandHandler.cs @@ -247,7 +247,9 @@ private async Task ResolveRejectionOffboardingReasonAsync( user.IalLevel, idProofingEligibilitySettings.Value.RequireQualifyingHouseholdForSocure); - var household = await TryGetHouseholdByEmailAsync( + var household = await IdProofingHouseholdLookup.TryGetByEmailForCohortCheckAsync( + householdRepository, + logger, user, warehouseIal, userId, @@ -256,32 +258,6 @@ private async Task ResolveRejectionOffboardingReasonAsync( return CoLoadedCohortClassifier.ResolveOffboardingReason(defaultReason, household); } - private async Task TryGetHouseholdByEmailAsync( - User user, - UserIalLevel warehouseIalForEmailReads, - Guid portalUserId, - CancellationToken cancellationToken) - { - try - { - return await householdRepository.GetHouseholdByEmailAsync( - user.Email!, - new PiiVisibility(IncludeAddress: false, IncludeEmail: false, IncludePhone: false), - warehouseIalForEmailReads, - portalUserId, - cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError( - ex, - "Household lookup failed ({ExceptionType}) for user {UserId} during DocV rejection cohort check", - ex.GetType().Name, - portalUserId); - return null; - } - } - private async Task UpdateUserProofingStatus(Guid userId, CancellationToken cancellationToken) { var user = await userRepository.GetUserByIdAsync(userId, cancellationToken); diff --git a/src/SEBT.Portal.UseCases/IdProofing/SubmitIdProofing/SubmitIdProofingCommandHandler.cs b/src/SEBT.Portal.UseCases/IdProofing/SubmitIdProofing/SubmitIdProofingCommandHandler.cs index d89ed27b0..a6d082de9 100644 --- a/src/SEBT.Portal.UseCases/IdProofing/SubmitIdProofing/SubmitIdProofingCommandHandler.cs +++ b/src/SEBT.Portal.UseCases/IdProofing/SubmitIdProofing/SubmitIdProofingCommandHandler.cs @@ -106,7 +106,9 @@ public async Task> Handle( logger.LogInformation( "User {UserId} has reached the maximum ID proofing attempts ({MaxAttempts})", command.UserId, maxAttempts); - var householdForMaxAttempts = await TryGetHouseholdByEmailAsync( + var householdForMaxAttempts = await IdProofingHouseholdLookup.TryGetByEmailForCohortCheckAsync( + householdRepository, + logger, user, warehouseIalForEmailReads, command.UserId, @@ -125,7 +127,9 @@ public async Task> Handle( // resolve the consumer, so national_id is optional for that path. if (string.IsNullOrWhiteSpace(command.IdType)) { - var householdForNoId = await TryGetHouseholdByEmailAsync( + var householdForNoId = await IdProofingHouseholdLookup.TryGetByEmailForCohortCheckAsync( + householdRepository, + logger, user, warehouseIalForEmailReads, command.UserId, @@ -502,29 +506,4 @@ private static bool IsExactlyNineDigitsAfterStripping(string? idValue) return digitCount == 9; } - private async Task TryGetHouseholdByEmailAsync( - User user, - UserIalLevel warehouseIalForEmailReads, - Guid portalUserId, - CancellationToken cancellationToken) - { - try - { - return await householdRepository.GetHouseholdByEmailAsync( - user.Email!, - new PiiVisibility(IncludeAddress: false, IncludeEmail: false, IncludePhone: false), - warehouseIalForEmailReads, - portalUserId, - cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError( - ex, - "Household lookup failed ({ExceptionType}) for user {UserId} during ID proofing cohort check", - ex.GetType().Name, - portalUserId); - return null; - } - } }