Skip to content

Financial history & job impact (technical)

Optional salary/insurance intake plus the comparison surface that answers "what changes for you" when the user is weighing a prospective job. Two pieces:

  1. FinancialProfile — an optional, separate profile document the user fills in "if they remember." Never required, never blocking any other flow.
  2. JobImpactService — combines a FinancialProfile with a job's enrichment data into a JobImpact (salary delta, take-home delta, cost-of-living-adjusted delta, insurance change, plain-language notes), shown as a compact card on the job's documents page.

Plain-language version: ../plain/financial-profile.md.

Privacy — read this first

FinancialProfile is local-only, full stop. Unlike IntakeProfile and StoryProfile (which are mirrored to a Drive results sheet as a sync/backup — see connections-and-sso.md), the financial profile is never synced, never uploaded, and never leaves the machine. It is stored the same way every other local document is stored — one JSON file under %USERPROFILE%\Wingman\data\financial.json, written by the same LocalJsonStore every other profile document uses — but nothing in this codebase ever attaches it to a GoogleSheetsClient/GoogleFormsClient call, and it must stay that way. If a future contributor is tempted to mirror it "for consistency" with IntakeProfile, that is the point to stop: salary and insurance history is more sensitive than application-form answers, and the whole feature is scoped around it staying on-device.

Every field on FinancialProfile and PastJob is nullable/optional. The UI frames the form as "optional — improves comparisons," never a blocking step, and a completely empty profile is a normal, fully-supported state (see ProfileServiceTests).

FinancialProfile — the model

src/WorkWingman.Core/Models/FinancialProfile.cs:

public class FinancialProfile
{
    public decimal? CurrentSalary { get; set; }
    public string? CurrentCity { get; set; }
    public string? CurrentInsuranceCarrier { get; set; }
    public List<PastJob> PastJobs { get; set; } = [];
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

public class PastJob
{
    public string? Company { get; set; }
    public string? Title { get; set; }
    public decimal? Salary { get; set; }
    public bool? WasContractor { get; set; }   // true = 1099, false = W-2, null = not specified
    public string? InsuranceCarrier { get; set; }
    public DateOnly? From { get; set; }
    public DateOnly? To { get; set; }
}

It is a new, separate model and store key ("financial", distinct from "intake") rather than an addition to IntakeProfile.Extended — deliberately, for two reasons: (1) IntakeProfile answers ATS application questions and is mirrored to Drive, and folding sensitive financial data into a type that's already wired to a sync path invites exactly the mistake the privacy section above warns against; (2) IntakeProfile is already a large aggregate — this keeps its diff, and its serialized shape, untouched.

Persistence — ProfileService extension

ProfileService gained two methods following the exact GetIntake/SaveIntake shape already established:

public async Task<FinancialProfile> GetFinancialAsync(CancellationToken ct = default)
    => await store.LoadAsync<FinancialProfile>("financial", ct) ?? new FinancialProfile();

public Task SaveFinancialAsync(FinancialProfile profile, CancellationToken ct = default)
{
    profile.UpdatedAt = DateTimeOffset.UtcNow;
    return store.SaveAsync("financial", profile, ct);
}

Exposed on ProfileController as GET/PUT /api/profile/financial — same route/verb/status-code conventions as /intake and /story (see api-reference.md).

JobImpact — the comparison output

src/WorkWingman.Core/Models/JobImpact.cs:

public class JobImpact
{
    public decimal? SalaryDelta { get; set; }
    public decimal? TakeHomeDelta { get; set; }
    public decimal? ColAdjustedDelta { get; set; }
    public InsuranceChange? InsuranceChange { get; set; }
    public List<string> Notes { get; set; } = [];
}

public class InsuranceChange
{
    public string? FromCarrier { get; set; }
    public string? ToCarrier { get; set; }
    public string? GenderAffirmingCareChange { get; set; }
}

Every field is nullable and independently computed — a job with no listed salary still produces a valid JobImpact with SalaryDelta = null and an explanatory note, not an error or an empty object. Exposed as GET /api/jobs/{jobId}/impact on JobsController.

The seam: JobImpactService and its three small local interfaces

JobImpactService needs inputs that three sibling branches, built in parallel, are responsible for:

Branch Provides Consumed via
feature-col-tax Take-home pay estimate; city cost-of-living comparison ITakeHomeCalculator, ICityComparisonService
feature-benefits-insight Carrier-to-carrier benefits comparison (incl. gender-affirming care) IBenefitsInsight
feature-comp-signals Market-rate compensation context ICompensationInsight

Rather than depend on those branches' real types (which don't exist on this branch), this feature defines its own small local interfaces for exactly what it consumes — src/WorkWingman.Core/Interfaces/IJobImpactInputs.cs:

public interface ITakeHomeCalculator
{
    decimal? EstimateTakeHome(decimal? grossSalary, string? city);
}

public interface ICityComparisonService
{
    decimal? ColAdjustmentFactor(string? fromCity, string? toCity);
}

public interface IBenefitsInsight
{
    string? CompareGenderAffirmingCareCoverage(string? fromCarrier, string? toCarrier);
}

public interface ICompensationInsight
{
    IReadOnlyList<string> Notes(decimal? currentSalary, decimal? newSalary, string? title);
}

Plus a plain job-side input value object, JobImpactInput (Salary, City, InsuranceCarrier, Title) — a deliberately decoupled projection of "a job's enrichment data" rather than a direct dependency on JobPosting or on any sibling branch's enrichment type.

Simple default/stub implementations (JobImpactStubs.cs) are registered in DI so this branch compiles, runs, and tests completely standalone:

public class StubTakeHomeCalculator : ITakeHomeCalculator
{
    // Flat ~72% of gross — good enough to exercise the seam, not tax advice.
    public decimal? EstimateTakeHome(decimal? grossSalary, string? city) =>
        grossSalary is null ? null : grossSalary * 0.72m;
}

public class StubCityComparisonService : ICityComparisonService
{
    public decimal? ColAdjustmentFactor(string? fromCity, string? toCity) => null; // no data yet
}

public class StubBenefitsInsight : IBenefitsInsight
{
    public string? CompareGenderAffirmingCareCoverage(string? fromCarrier, string? toCarrier) => null;
}

public class StubCompensationInsight : ICompensationInsight
{
    public IReadOnlyList<string> Notes(decimal? currentSalary, decimal? newSalary, string? title) => [];
}

What integration must do

JobImpactService itself should not need to change. Integration swaps four DI registrations in Program.cs:

builder.Services.AddSingleton<ITakeHomeCalculator, StubTakeHomeCalculator>();       // → real feature-col-tax type
builder.Services.AddSingleton<ICityComparisonService, StubCityComparisonService>(); // → real feature-col-tax type
builder.Services.AddSingleton<IBenefitsInsight, StubBenefitsInsight>();             // → real feature-benefits-insight type
builder.Services.AddSingleton<ICompensationInsight, StubCompensationInsight>();     // → real feature-comp-signals type

...and populates the rest of JobImpactInput once JobPosting (or an enrichment record attached to it) carries a salary/carrier — today JobsController.GetImpact fills in Title and City (from the already-scraped JobPosting.Location); Salary and InsuranceCarrier don't exist on JobPosting yet on this branch and land with feature-comp-signals/feature-benefits-insight.

The calculation

JobImpactService.Calculate:

  • SalaryDelta = job.Salary - profile.CurrentSalary, null unless both sides are known.
  • TakeHomeDelta = takeHome(job) - takeHome(current), via ITakeHomeCalculator on both sides; null if either estimate comes back null.
  • ColAdjustedDelta restates the job's take-home (earned in the job's city) into the current city's purchasing power, then subtracts the current take-home: newTakeHome * ColAdjustmentFactor(job.City → currentCity) - currentTakeHome. Adjusting the baseline — not just the delta — is what makes "equal take-home to a pricier city" correctly read as a loss rather than 0. Null if either take-home estimate or the factor is null (a 0 factor is a valid, non-null answer and still produces a concrete number).
  • InsuranceChange is set whenever either carrier is known (not only both) — a user who knows their current carrier but not the job's yet still gets a partial comparison scaffold. GenderAffirmingCareChange comes from IBenefitsInsight, framed as non-diagnostic — the UI always pairs it with a prompt to confirm against the actual plan document.
  • Notes always explains why a delta is missing (no salary on file at all / only one side known / job doesn't list one) before appending whatever ICompensationInsight contributes.

Testing

  • Backend: ProfileServiceTests.cs covers the financial round-trip (empty state, round-trip, UpdatedAt stamping, nullable PastJob fields surviving serialization). JobImpactServiceTests.cs covers null-argument guards, every delta's null-tolerance branch, the insurance-change either-side-known rule, and note composition — using both the shipped stubs and small test-local fakes (e.g. a fixed cost-of-living factor) to exercise math the always-null stubs can't reach. TestData.cs gained seeded Bogus builders for FinancialProfile, PastJob, and JobImpactInput.
  • Frontend: financial-history.spec.ts covers load, add/remove past job, save (success and failure — a failed save must never show the success toast), skip, and the carrier-suggestion list. job-impact-card.spec.ts covers the show/hide contract (hidden when every field is empty, shown when only Notes is populated, hidden gracefully on API failure).
  • Mutation testing: tests/WorkWingman.Tests/stryker-config.json mutates **/Enrichment/*.cs (excluding the throwaway JobImpactStubs.cs) alongside its existing scope; ProfileService.cs financial methods and JobImpactService.cs are both included.

Frontend

  • FinancialHistory (frontend/src/app/features/financial-history/) — standalone, signals-based form matching the onboarding/story screens' visual style. Add/remove past jobs via signal .update(); a W-2/1099/"not sure" pill group explains the distinction inline; insurance carrier fields use an HTML <datalist> (native autocomplete, free text still allowed) seeded from insurance-carriers.ts. Routed at /financial-history, linked from the sidebar next to "Your story."
  • JobImpactCard (frontend/src/app/features/documents/job-impact-card.ts) — compact card added to the job documents page (/documents/:jobId), the app's per-job detail view. Calls GET /api/jobs/{jobId}/impact and renders nothing (hasData() is false) when every field is null and there are no notes — an empty financial profile means the card simply doesn't appear, not a placeholder/empty state.