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:
FinancialProfile— an optional, separate profile document the user fills in "if they remember." Never required, never blocking any other flow.JobImpactService— combines aFinancialProfilewith a job's enrichment data into aJobImpact(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¶
SalaryDelta=job.Salary - profile.CurrentSalary, null unless both sides are known.TakeHomeDelta=takeHome(job) - takeHome(current), viaITakeHomeCalculatoron both sides; null if either estimate comes back null.ColAdjustedDeltarestates 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 than0. Null if either take-home estimate or the factor is null (a0factor is a valid, non-null answer and still produces a concrete number).InsuranceChangeis 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.GenderAffirmingCareChangecomes fromIBenefitsInsight, framed as non-diagnostic — the UI always pairs it with a prompt to confirm against the actual plan document.Notesalways explains why a delta is missing (no salary on file at all / only one side known / job doesn't list one) before appending whateverICompensationInsightcontributes.
Testing¶
- Backend:
ProfileServiceTests.cscovers the financial round-trip (empty state, round-trip,UpdatedAtstamping, nullablePastJobfields surviving serialization).JobImpactServiceTests.cscovers 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.csgained seeded Bogus builders forFinancialProfile,PastJob, andJobImpactInput. - Frontend:
financial-history.spec.tscovers 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.tscovers the show/hide contract (hidden when every field is empty, shown when onlyNotesis populated, hidden gracefully on API failure). - Mutation testing:
tests/WorkWingman.Tests/stryker-config.jsonmutates**/Enrichment/*.cs(excluding the throwawayJobImpactStubs.cs) alongside its existing scope;ProfileService.csfinancial methods andJobImpactService.csare 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 frominsurance-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. CallsGET /api/jobs/{jobId}/impactand renders nothing (hasData()isfalse) 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.