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/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 f68120e55..2c0416c0c 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,33 @@ 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 IdProofingHouseholdLookup.TryGetByEmailForCohortCheckAsync(
+ householdRepository,
+ logger,
+ user,
+ warehouseIal,
+ userId,
+ cancellationToken);
+
+ return CoLoadedCohortClassifier.ResolveOffboardingReason(defaultReason, household);
+ }
+
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 4241b2251..a6d082de9 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,47 @@ public async Task> Handle(
logger.LogInformation(
"User {UserId} has reached the maximum ID proofing attempts ({MaxAttempts})",
command.UserId, maxAttempts);
+ var householdForMaxAttempts = await IdProofingHouseholdLookup.TryGetByEmailForCohortCheckAsync(
+ householdRepository,
+ logger,
+ 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 IdProofingHouseholdLookup.TryGetByEmailForCohortCheckAsync(
+ householdRepository,
+ logger,
+ 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 +172,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 +250,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 +377,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 +505,5 @@ private static bool IsExactlyNineDigitsAfterStripping(string? idValue)
}
return digitCount == 9;
}
+
}
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/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]
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()
{