Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
98cae26
Fix CardStatus integer-to-string mapping to match backend enum values
Mar 24, 2026
52cb693
Move CardSelection component to features/cards
Mar 24, 2026
ac71d15
Add stubbed RequestCardReplacement command, handler, and tests
Mar 24, 2026
367ba8f
Add POST /api/household/cards/replace endpoint and controller tests
Mar 24, 2026
822c5d9
Add card replacement API client hook and request schema
Mar 24, 2026
c0b93de
Add ConfirmRequest pre-submission review component
Mar 24, 2026
3eb01e4
Wire CardSelection to ConfirmRequest with sibling auto-select
Mar 24, 2026
c06753c
Add standalone card replacement flow with ConfirmAddress and routes
Mar 24, 2026
414b3b9
Add co-loaded card info route reusing existing CoLoadedInfo component
Mar 24, 2026
9202bdb
Add replacement card links to ChildCard and update ActionButtons CTA
Mar 24, 2026
83a6ad5
Add card replacement success alert to DashboardAlerts with PII-safe f…
Mar 24, 2026
ca946d5
Add 2-week cooldown utility and wire into ChildCard and CardSelection
Mar 24, 2026
825289c
Seed MockHouseholdRepository Faker for stable application numbers
Mar 24, 2026
3ec57b6
Fix mock data instability and remove co-loaded Continue button
Mar 24, 2026
dc22564
Set IssuanceType on mock household applications for realistic card di…
Mar 24, 2026
42bc374
Replace FIS phone content with DHS EBT Card Office locations in CoLoa…
Mar 24, 2026
3d89261
Show SEBT ID in ChildCard and fix cardTableActionRequestReplacement key
Mar 24, 2026
9ffc67a
Remove counters-sm class so timeline step labels render
Mar 24, 2026
113e647
Add dashboard navigation link to card info page alert
Mar 24, 2026
d218ed0
Register RequestCardReplacementCommand handler and add MSW stub
Mar 24, 2026
a87a667
Fix CardSelection confirm navigation to resolve as child route
Mar 24, 2026
d185074
Set Last4DigitsOfCard on all approved mock household scenarios
Mar 24, 2026
62eb5c4
Set explicit CardRequestedAt on all approved mock scenarios to avoid …
Mar 24, 2026
24a4a43
Add Playwright E2E tests for card replacement flow (42 tests)
Mar 25, 2026
083f07e
Fix E2E CardStatus enum values, remove double decodeURIComponent, add…
Mar 25, 2026
96c01c0
Merge main into DC-153 (design-system extraction + DC-130 dashboard)
Mar 26, 2026
e395546
Fix replacement card link routing and cooldown bypass on dashboard
Mar 27, 2026
7c56e8e
Use IssuerSigningKeyResolver for kid-less JWTs and suppress USWDS sas…
Mar 27, 2026
bc59ebf
Update locale CSVs and support new Google Sheet column headers
Mar 27, 2026
e9f5200
Add commented helper for CO OIDC local testing in mock data
Mar 27, 2026
3633f95
Merge main into DC-153
Mar 27, 2026
63b3cac
Extract shared IAL resolution, fix stub log message, fix office address
Mar 28, 2026
16ac3f8
Reject card replacement requests with application numbers not in hous…
Mar 29, 2026
1514a94
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 1, 2026
0bf8d88
Address PR review: inject TimeProvider, add null-household test, remo…
Apr 1, 2026
3732888
Adopt UserIalLevelExtensions from DC-194 to avoid merge conflict
Apr 1, 2026
7e19f00
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 1, 2026
bdc9c83
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini Apr 1, 2026
840f560
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini Apr 1, 2026
7a75664
Fix E2E workflow timeout and add test observability
Apr 2, 2026
942bedd
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 2, 2026
08f16a7
Split Playwright and Pa11y into parallel CI jobs
Apr 2, 2026
3e2596a
Remove stub page that shadows address form route
Apr 2, 2026
6228bc6
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini Apr 2, 2026
e7f68c8
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
necampanini Apr 2, 2026
29ffe26
Use real addresses in mock data for Smarty validation compatibility
Apr 3, 2026
e624f35
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 3, 2026
2d2d3ac
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 6, 2026
03cef78
Fix CoLoadedInfo test fixture addresses to match updated MSW mock data
Apr 6, 2026
a80c060
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 6, 2026
7e3aed1
split mixed-state E2E tests and add per-state CI matrix
Apr 6, 2026
d47db49
fix EnrolledChildren field mapping and update E2E fixtures for summer…
Apr 6, 2026
55c83d3
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
587614e
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
4a8c6ef
gate replacement link behind feature flag, improve CardSelection empt…
Apr 7, 2026
3a0cce7
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
304b5fc
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
5e0457c
Merge branch 'main' into feat/DC-153-request-replacement-card-flow
Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/SEBT.Portal.Api/Controllers/Household/HouseholdController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,34 @@ public async Task<IActionResult> UpdateAddress(
var result = await commandHandler.Handle(command, cancellationToken);
return result.ToActionResult();
}

/// <summary>
/// Requests replacement cards for the authenticated user's household.
/// </summary>
/// <param name="request">The application numbers to request replacements for.</param>
/// <param name="commandHandler">The use case handler for requesting card replacements.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>No content on success; otherwise, BadRequest or Unauthorized.</returns>
/// <response code="204">Card replacement request recorded successfully.</response>
/// <response code="400">Validation failed (no applications selected or cooldown active).</response>
/// <response code="403">User is not authorized or no household identifier could be resolved from token.</response>
[HttpPost("cards/replace")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
public async Task<IActionResult> RequestCardReplacement(
[FromBody] RequestCardReplacementRequest request,
[FromServices] ICommandHandler<RequestCardReplacementCommand> commandHandler,
CancellationToken cancellationToken = default)
{
var command = new RequestCardReplacementCommand
{
User = User,
ApplicationNumbers = request.ApplicationNumbers
};

var result = await commandHandler.Handle(command, cancellationToken);
return result.ToActionResult();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;

namespace SEBT.Portal.Api.Models.Household;

/// <summary>
/// Request model for requesting replacement cards for one or more applications.
/// </summary>
public record RequestCardReplacementRequest
{
/// <summary>Application numbers identifying which cards to replace.</summary>
[Required(ErrorMessage = "At least one application number is required.")]
[MinLength(1, ErrorMessage = "At least one application number is required.")]
public required List<string> ApplicationNumbers { get; init; }
}
2 changes: 1 addition & 1 deletion src/SEBT.Portal.Infrastructure/Dependencies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public static IServiceCollection AddPortalInfrastructureRepositories(
"UseMockHouseholdData is false but no household plugin (ISummerEbtCaseService) is loaded. " +
"Either set UseMockHouseholdData to true in configuration or ensure a state plugin is loaded (e.g. PluginAssemblyPaths and the plugin DLL).");
});
services.AddTransient<MockHouseholdRepository>();
services.AddSingleton<MockHouseholdRepository>();
services.AddTransient<HouseholdRepository>();

services.AddMemoryCache();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@

if (household == null)
{
_logger.LogInformation("Mock household not found for identifier {Type}={Value}", identifier.Type, normalizedEmail);

Check warning

Code scanning / CodeQL

Exposure of private information Medium

Private data returned by
access to parameter email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by access to parameter email is written to an external location.
Private data returned by access to parameter email is written to an external location.
Private data returned by access to local variable normalizedEmail is written to an external location.
Private data returned by access to parameter email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to parameter email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by call to method BuildEmail is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by call to method BuildEmail is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable normalizedEmail is written to an external location.
return Task.FromResult<HouseholdData?>(null);
}

Expand All @@ -72,7 +72,7 @@
_logger.LogDebug(
"Returning mock household data for identifier {Type}={Value}, PII visibility: Address={IncludeAddress}, Email={IncludeEmail}, Phone={IncludePhone}",
identifier.Type,
normalizedEmail,

Check warning

Code scanning / CodeQL

Exposure of private information Medium

Private data returned by
access to parameter email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by access to parameter email is written to an external location.
Private data returned by access to parameter email is written to an external location.
Private data returned by access to local variable normalizedEmail is written to an external location.
Private data returned by access to parameter email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to parameter email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by call to method BuildEmail is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by call to method BuildEmail is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable email is written to an external location.
Private data returned by access to local variable normalizedEmail is written to an external location.
piiVisibility.IncludeAddress,
piiVisibility.IncludeEmail,
piiVisibility.IncludePhone);
Expand Down Expand Up @@ -131,9 +131,11 @@
var app = h.Applications.FirstOrDefault();
if (app != null)
{
app.IssuanceType = IssuanceType.SnapEbtCard;
app.BenefitIssueDate = now.AddDays(-20);
app.BenefitExpirationDate = now.AddDays(70);
app.Last4DigitsOfCard = "0000";
app.CardRequestedAt = now.AddDays(-60);
// Set specific children names for test
app.Children = new List<Child>
{
Expand Down Expand Up @@ -162,6 +164,9 @@
var app = h.Applications.FirstOrDefault();
if (app != null)
{
app.ApplicationNumber = "APP-2025-01-100001";
app.CaseNumber = "CASE-100001";
app.IssuanceType = IssuanceType.SummerEbt;
app.BenefitIssueDate = now.AddDays(-30);
app.BenefitExpirationDate = now.AddDays(60);
app.Last4DigitsOfCard = "1234"; // Specific value for test
Expand Down Expand Up @@ -322,8 +327,11 @@
var app = h.Applications.FirstOrDefault();
if (app != null)
{
app.IssuanceType = IssuanceType.SummerEbt;
app.BenefitIssueDate = now.AddDays(-15);
app.BenefitExpirationDate = now.AddDays(75);
app.Last4DigitsOfCard = "4321";
app.CardRequestedAt = now.AddDays(-45);
// Use Bogus to generate child name
var childFaker = new Faker<Child>()
.RuleFor(c => c.FirstName, f => f.Name.FirstName())
Expand All @@ -343,8 +351,11 @@
var app = h.Applications.FirstOrDefault();
if (app != null)
{
app.IssuanceType = IssuanceType.TanfEbtCard;
app.BenefitIssueDate = now.AddDays(-45);
app.BenefitExpirationDate = now.AddDays(45);
app.Last4DigitsOfCard = "8765";
app.CardRequestedAt = now.AddDays(-30);
// Set specific children names for test
app.Children = new List<Child>
{
Expand Down Expand Up @@ -385,6 +396,8 @@
{
app.BenefitIssueDate = now.AddDays(-120);
app.BenefitExpirationDate = now.AddDays(-10); // Expired
app.Last4DigitsOfCard = "9012";
app.CardRequestedAt = now.AddDays(-90);
// Use Bogus to generate child name
var childFaker = new Faker<Child>()
.RuleFor(c => c.FirstName, f => f.Name.FirstName())
Expand Down Expand Up @@ -416,12 +429,13 @@
var multipleApps = HouseholdFactory.CreateHouseholdDataWithStatus(ApplicationStatus.Approved, h =>
{
h.BenefitIssuanceType = BenefitIssuanceType.SnapEbtCard;
var faker = new Faker();
var faker = new Faker { Random = new Randomizer(42) };
var approvedApp = new Application
{
ApplicationNumber = $"APP-{now.AddDays(-30):yyyy-MM}-{faker.Random.Number(100000, 999999)}",
CaseNumber = $"CASE-{faker.Random.Number(100000, 999999)}",
ApplicationStatus = ApplicationStatus.Approved,
IssuanceType = IssuanceType.SummerEbt,
BenefitIssueDate = now.AddDays(-30),
BenefitExpirationDate = now.AddDays(60),
Last4DigitsOfCard = "5678",
Expand Down
6 changes: 3 additions & 3 deletions src/SEBT.Portal.TestUtilities/Helpers/HouseholdFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,

if (status == ApplicationStatus.Approved)
{
application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
application.CaseNumber = $"CASE-{faker.Random.Number(100000, 999999)}";
application.BenefitIssueDate = faker.Date.Recent(120);
application.BenefitExpirationDate = application.BenefitIssueDate.Value.AddDays(faker.Random.Int(30, 365));
Expand All @@ -134,7 +134,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,
}
else if (status == ApplicationStatus.Denied)
{
application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
application.CaseNumber = $"CASE-{faker.Random.Number(100000, 999999)}";
if (faker.Random.Bool(0.5f))
{
Expand Down Expand Up @@ -163,7 +163,7 @@ private static Application CreateApplicationWithStatus(ApplicationStatus status,
else
{
// For other statuses (Pending, UnderReview, Cancelled)
application.ApplicationNumber = $"APP-{faker.Date.Recent(365):yyyy-MM}-{faker.Random.Number(100000, 999999)}";
application.ApplicationNumber = $"APP-{faker.Random.Number(2024, 2026)}-{faker.Random.Number(1, 12):D2}-{faker.Random.Number(100000, 999999)}";
if (faker.Random.Bool(0.5f))
{
application.CardStatus = CardStatus.Requested;
Expand Down
1 change: 1 addition & 0 deletions src/SEBT.Portal.UseCases/Dependencies.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static IServiceCollection AddUseCases(this IServiceCollection services)
services.RegisterQueryHandler<GetVerificationStatusQuery, VerificationStatusResponse, GetVerificationStatusQueryHandler>();
services.RegisterCommandHandler<ProcessWebhookCommand, ProcessWebhookCommandHandler>();
services.RegisterCommandHandler<UpdateAddressCommand, UpdateAddressCommandHandler>();
services.RegisterCommandHandler<RequestCardReplacementCommand, RequestCardReplacementCommandHandler>();

return services;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using SEBT.Portal.Kernel;

namespace SEBT.Portal.UseCases.Household;

/// <summary>
/// Command to request replacement cards for one or more applications.
/// </summary>
public class RequestCardReplacementCommand : ICommand
{
/// <summary>
/// The authenticated user's claims principal, used to resolve household identity.
/// </summary>
[Required]
public required ClaimsPrincipal User { get; init; }

/// <summary>
/// Application numbers identifying which cards to replace.
/// All children on a selected application share the same card.
/// </summary>
[Required(ErrorMessage = "At least one application number is required.")]
[MinLength(1, ErrorMessage = "At least one application number is required.")]
public required List<string> ApplicationNumbers { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Microsoft.Extensions.Logging;
using SEBT.Portal.Core.Models;
using SEBT.Portal.Core.Models.Auth;
using SEBT.Portal.Core.Repositories;
using SEBT.Portal.Core.Services;
using SEBT.Portal.Kernel;
using SEBT.Portal.Kernel.Results;

namespace SEBT.Portal.UseCases.Household;

/// <summary>
/// Handles card replacement requests for an authenticated user's household.
/// Validates input, resolves household identity, enforces 2-week cooldown, and returns success.
/// State connector call is stubbed — actual card replacement is a future integration.
/// </summary>
public class RequestCardReplacementCommandHandler(
IValidator<RequestCardReplacementCommand> validator,
IHouseholdIdentifierResolver resolver,
IHouseholdRepository repository,
ILogger<RequestCardReplacementCommandHandler> logger)
: ICommandHandler<RequestCardReplacementCommand>
{
private static readonly TimeSpan CooldownPeriod = TimeSpan.FromDays(14);

public async Task<Result> Handle(
RequestCardReplacementCommand command,
CancellationToken cancellationToken = default)
{
var validationResult = await validator.Validate(command, cancellationToken);
if (validationResult is ValidationFailedResult validationFailed)
{
logger.LogWarning("Card replacement validation failed");
return Result.ValidationFailed(validationFailed.Errors);
}

var identifier = await resolver.ResolveAsync(command.User, cancellationToken);
if (identifier == null)
{
logger.LogWarning(
"Card replacement attempted but no household identifier could be resolved from claims");
return Result.Unauthorized("Unable to identify user from token.");
}

var household = await repository.GetHouseholdByIdentifierAsync(
identifier,
new PiiVisibility(IncludeAddress: false, IncludeEmail: false, IncludePhone: false),
UserIalLevel.None,
cancellationToken);

if (household == null)
{
logger.LogWarning("Card replacement attempted but household data not found");
return Result.PreconditionFailed(PreconditionFailedReason.NotFound, "Household data not found.");
}

var cooldownErrors = CheckCooldown(command.ApplicationNumbers, household);
if (cooldownErrors.Count > 0)
{
logger.LogInformation(
"Card replacement rejected: {Count} application(s) within cooldown period",
cooldownErrors.Count);
return Result.ValidationFailed(cooldownErrors);
}

var identifierKind = identifier.Type.ToString();
logger.LogInformation(
"Card replacement request received for household identifier kind {Kind}, {Count} application(s)",
identifierKind,
command.ApplicationNumbers.Count);

// TODO: Call state connector to process card replacement.
// Stubbed — returns success without calling the state system.

logger.LogInformation(
"Card replacement completed for household identifier kind {Kind}",
identifierKind);

return Result.Success();
}

private static List<ValidationError> CheckCooldown(
List<string> requestedApplicationNumbers,
Core.Models.Household.HouseholdData household)
{
var errors = new List<ValidationError>();
var now = DateTime.UtcNow;

foreach (var appNumber in requestedApplicationNumbers)
{
var application = household.Applications
.FirstOrDefault(a => a.ApplicationNumber == appNumber);

if (application?.CardRequestedAt == null)
continue;

var elapsed = now - application.CardRequestedAt.Value;
if (elapsed < CooldownPeriod)
{
errors.Add(new ValidationError(
"ApplicationNumbers",
$"Application {appNumber} was requested within the last 14 days."));
}
}

return errors;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using SEBT.Portal.Kernel;
using SEBT.Portal.Kernel.Results;

namespace SEBT.Portal.UseCases.Household;

/// <summary>
/// Validates <see cref="RequestCardReplacementCommand"/> using data annotations.
/// </summary>
public class RequestCardReplacementCommandValidator(
IValidator<RequestCardReplacementCommand> validator)
: IValidator<RequestCardReplacementCommand>
{
public Task<ValidationResult> Validate(
RequestCardReplacementCommand command,
CancellationToken cancellationToken = default)
=> validator.Validate(command, cancellationToken);
}
Loading
Loading