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, a separate cost-of-living index ratio, insurance change, plain-language notes), shown as a compact card on the job's documents page. Take-home delta and the COL ratio are reported side by side and never blended — see "The calculation" below.
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? ColIndexRatio { 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
{
// Returns the full TaxEstimate (not just the take-home number) so callers can run
// TakeHomeHonestyGuard against it (WW-131 council-code-review finding).
TaxEstimate? 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. Leaves every
// TaxEstimate completeness flag at its honest fail-closed default.
public TaxEstimate? EstimateTakeHome(decimal? grossSalary, string? city) =>
grossSalary is null ? null : new TaxEstimate { Gross = grossSalary.Value, TakeHome = grossSalary.Value * 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.ColIndexRatiois a plain cost-of-living index comparison — the job's city index over the current city's (ColAdjustmentFactor(currentCity → job.City)), shown as its own labeled field, never multiplied intoTakeHomeDelta(council ruling, WW-131: an earlier version restated the job's take-home into the current city's purchasing power and subtracted, producing a single blended scalar that read as one fact when it was really two stacked estimates — deleted outright, not deprecated). Computed independent ofTakeHomeDelta's nullness: it only needs both cities, so it can be populated even when neither salary is on file. Null only when either city is unknown or COL data is missing for one of them (a0factor is still a valid, non-null answer).- Honesty disclosure: whenever
TakeHomeDeltaorColIndexRatiois shown,HonestyCaveats(kept separate fromNotesso a disclaimer never gets misread as a data source) is populated by running each known side'sTaxEstimatethroughTakeHomeHonestyGuard.Evaluate, labeled by that side's city — the same guardCityComparisonServiceandOfferEvaluationService's Real Comp axis use. Falls back to the bareNotTaxAdviceDisclaimerwhen neither side has aTaxEstimateat all (a COL-only figure with no salary on file). 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.