Skip to content

WorkWingman — Cost of Living & Income Tax (Technical)

Two comparable numbers for a job's city: cost of living (gas, groceries, rent — relative indexes/prices) and an income-tax estimate for a given salary in that city (federal, FICA, state, and notable local taxes). Same hard rule as job-signals.md: every source must be legally/officially published, free where a free option exists, and paid sources are wired as inert, config-gated seams rather than assumed or hardcoded.

This is an estimate, not tax advice. The tax engine is a simplified model of published bracket tables — it does not account for itemized deductions, credits, retirement contributions, pre-tax benefits, self-employment tax, multi-state residency, or any of the dozens of circumstances a real tax return has to handle. Use it to compare offers directionally, not to file a return or make a legal/financial decision.

Cost of living — sources evaluated and the decision

Source Verdict Why
EIA (U.S. Energy Information Administration) Used (EiaGasSource) Free, official, keyed government API (https://www.eia.gov/opendata/), route petroleum/pri/gnd/data for weekly regular-gasoline retail prices by PAD District. GasBuddy has no public API at all, so EIA is the legal substitute for gas prices. Registration is free at https://www.eia.gov/opendata/register.php.
BLS (Bureau of Labor Statistics) CPI Used (BlsCpiSource) Free, official, keyed government API (https://www.bls.gov/developers/, Public Data API v2, https://api.bls.gov/publicAPI/v2/timeseries/data/). Registration is free at https://data.bls.gov/registrationEngine/. Used for "rent of primary residence" and "food at home" (grocery) CPI item series, at the four-Census-region level.
MIT Living Wage Calculator Not used Explicitly disallows scraping/extraction/export; more than 10 locations requires a licensing request through the Living Wage Institute's contact form, not a public API. No free programmatic path exists.
C2ER Cost of Living Index (COLI) Not used — paid, documented only Licensing-only product (https://www.coli.org/products/licensing/); pricing is negotiated per-organization (older public pricing references ranged from ~$165/year for narrow use up to full-catalog licenses). No API; a full or partial "license" is the only access path. Not integrated.
Numbeo Config-stubbed, not integrated by default (NumbeoSource) Has a real Cost of Living API (https://www.numbeo.com/common/api.jsp) with commercial usage rights, but it is paid only — Single User plan $260/month, Premier Multiple Users $560/month, no free tier. See "Numbeo config-stubbed adapter" below.
Census ACS property tax Used (PropertyTaxSource) Free, official, unauthenticated ACS API family (same as the housing CensusAcsSource): table B25103 ("Mortgage Status by Median Real Estate Taxes Paid (Dollars)"), variable B25103_001E (total median annual real estate taxes). Metro coverage limited to the same ~10-metro CBSA lookup reused from CensusAcsSource.ResolveCbsaCode (Austin, Dallas, Seattle, San Francisco, New York, Denver, Chicago, Atlanta, Boston, Phoenix). Unmapped city/state → empty contribution, no network call.
Car reference table (bundled) Used (CarCostSource) Not a live API — bundled state-level reference data: average monthly full-coverage auto insurance (ValuePenguin's State of Auto Insurance report; NAIC / S&P Global RateWatch / Quadrant Information Services data, quotes gathered Oct–Dec 2024) plus average annual registration/documentation fees (World Population Review's state DMV fee compilation, updated 2026-07-17). Combined into one annual Car category (monthlyInsurance × 12 + annualRegistration). DC has insurance but no published registration figure, so the Car category is unavailable for DC rather than guessed.

Regional approximation (read this before trusting a number)

Neither EIA nor BLS's free API publishes data at arbitrary-city granularity:

  • EIA gas prices are published per PAD District (5 regions covering the entire US — see the PADD map). A job in Austin, TX and a job in Houston, TX get the same PADD 3 gas price.
  • BLS CPI rent/grocery indexes used here are published at the four-Census-region level (Northeast / Midwest / South / West — see BLS's regional resources). A job in Boston and a job in Philadelphia get the same Northeast-region index.

StateRegionMap (src/WorkWingman.Infrastructure/Enrichment/StateRegionMap.cs) maps each state to its PADD and Census region — a documented, official grouping, not an invented scheme. City-level precision would require a paid source (Numbeo, C2ER) or a state-by-state manual dataset; this is the honest tradeoff of staying on free, legal, official data.

BLS is a proxy, not a true COL index (read this too)

This is a second, separate caveat from the regional-approximation one above, and it matters more: CPI measures inflation over time within a region, not the price-level difference between regions. BLS's "rent of primary residence" and "food at home" series answer "how have prices in the Midwest changed since CPI's base period?" — they do not answer "is the Midwest cheaper or more expensive than the West right now?" Two regions can carry very similar CPI values while having very different absolute rents and grocery bills, because CPI's index point is anchored to that region's own history, not to a shared cross-region baseline.

Numbeo's indices are the opposite: genuinely cross-sectional. Numbeo publishes its rent/groceries indexes on a shared baseline (New York City = 100), so a Numbeo rent index of 60 for one city and 120 for another really does mean "roughly half the rent." That's what CityComparisonService's COL-adjustment math (a ratio of the two sides' average index) actually needs to be meaningful.

Practical consequence: when Numbeo is configured, CostOfLivingSnapshot.Sources will include Numbeo and the resulting ColIndexRatio context figure is on reasonably solid ground. When BLS is the only configured source for rent/groceries (the common case, since Numbeo is a paid subscription), Sources will show BlsCpi instead — treat ColIndexRatio as a rough directional signal, not a precise "X% cheaper" claim. This is exactly why Sources exists on the snapshot: check it before trusting the number the same way you'd check which wire service filed a news story.

CostOfLivingSnapshot

public class CostOfLivingSnapshot
{
    public string City { get; set; }
    public double? RentIndex { get; set; }         // BLS CPI, regional, unitless index
    public double? GroceryIndex { get; set; }       // BLS CPI "food at home", regional, unitless index
    public double? GasPricePerGallon { get; set; }  // EIA, regional (PADD), USD
    public List<CostOfLivingCategory> Categories { get; set; } = [];  // per-category breakdown (additive)
    public List<CostOfLivingSourceKind> Sources { get; set; } = [];
    public DateTimeOffset AsOf { get; set; }
}

Every field is nullable, mirroring LayoffEvent/CompanyLayoffService's graceful-degradation contract: a source that's down, unconfigured, or has nothing for a region leaves its field(s) null rather than failing the whole snapshot.

Per-category breakdown (Categories)

CostOfLivingSnapshot.Categories is an additive per-kind breakdown — it never replaces the scalar fields (RentIndex / GroceryIndex / GasPricePerGallon), which stay filled exactly as before for existing consumers (RealColAdjustmentProvider, CityComparisonService).

public class CostOfLivingCategory
{
    public CostOfLivingCategoryKind Kind { get; set; }
    public double? Value { get; set; }
    public string Unit { get; set; } = string.Empty;   // e.g. "USD/year"
    public CostOfLivingSourceKind Source { get; set; }
    public string Citation { get; set; } = string.Empty;
    public string AsOf { get; set; } = string.Empty;   // vintage label, not a wall-clock date
}

public enum CostOfLivingCategoryKind { Housing, Grocery, Gas, PropertyTax, Car, Commute }

What is populated today:

  • PropertyTax — from PropertyTaxSource (Census ACS B25103_001E), unit USD/year.
  • Car — from CarCostSource (bundled insurance + registration reference table), unit USD/year.

What is not (yet) backfilled into Categories:

  • Housing / Grocery / Gas already exist as the snapshot's scalar fields and have not been duplicated into this list.
  • Commute is sourced separately by CommuteCostService / CommuteCostProjection, not this snapshot.

CostOfLivingService.Merge applies the same first-configured-source-wins rule per CostOfLivingCategoryKind that it already applies to scalar fields: the first registered source to answer a given Kind fills it; later sources offering the same Kind are dropped. Adding a category also counts as "wrote a field" for Sources attribution — a fully-shadowed source still stays out of Sources.

Source adapter interface

public interface ICostOfLivingSource
{
    CostOfLivingSourceKind Source { get; }
    bool IsConfigured { get; }   // false => never called for a live lookup
    Task<CostOfLivingSnapshot> LookupAsync(string city, string state, CancellationToken ct = default);
}

Aggregation (CostOfLivingService)

src/WorkWingman.Infrastructure/Enrichment/CostOfLivingService.cs queries every registered ICostOfLivingSource, skips any that report IsConfigured == false, and merges whichever fields each source actually populated into one snapshot — first configured source to answer a field wins (registration order is priority, same idea as CompanyLayoffService.SourcePriority, just per-field instead of per-event). One source throwing never blocks the others; OperationCanceledException still propagates.

Numbeo config-stubbed adapter

NumbeoSource (src/WorkWingman.Infrastructure/Enrichment/NumbeoSource.cs) is registered in Program.cs like every other source, but its API key is read only from configuration/environment (WorkWingman:Numbeo:ApiKey or the NUMBEO_API_KEY environment variable) — never hardcoded, never assumed present. With no key configured (the default — no one has purchased a Numbeo subscription for this repo), IsConfigured is false and every lookup is a no-op returning an all-null contribution. This keeps the seam ready: the day a key is added to configuration, Numbeo lights up with zero code changes, still gated by the same IsConfigured check CostOfLivingService already honors for every source.

Income tax — the estimator

IncomeTaxEstimator (src/WorkWingman.Infrastructure/Tax/IncomeTaxEstimator.cs) is pure and deterministic — no network call anywhere in this type. It loads a versioned local JSON dataset once and computes everything from marginal-bracket math.

public interface IIncomeTaxEstimator
{
    TaxEstimate Estimate(decimal gross, string state, string? city = null, FilingStatus filingStatus = FilingStatus.Single);
}

Dataset

Tax/TaxData/tax-data-2026.json (shape: TaxDataset.cs), copied to the output directory on build. Tagged "dataVintage": "2026.2" and "asOf": "2026-01-01". A new tax year ships as a new JSON file plus a bumped vintage string — the engine never calls out to refresh this data.

Coverage:

  • Federal: all 7 brackets (10/12/22/24/32/35/37%) + the standard deduction, for all four filing statuses (Single, MarriedJointly, HeadOfHousehold, MarriedSeparately — WW-127), 2026 figures per IRS Rev. Proc. 2025-32 / One Big Beautiful Bill Act amendments.
  • FICA: Social Security (6.2%, capped at the $184,500 2026 wage base), Medicare (1.45%, uncapped), and the 0.9% Additional Medicare surtax above a per-filing-status threshold (26 U.S.C. §3101(b)(2), not inflation-indexed): $200,000 for Single/HeadOfHousehold, $250,000 for MarriedJointly, $125,000 for MarriedSeparately.
  • State: all 50 states + DC, brackets modeled once (see the filing-status caveat below):
  • 9 no-tax states: AK, FL, NV, NH, SD, TN, TX, WY, WA (Washington taxes capital gains only, not modeled here as it's not a wage/salary tax).
  • 17 flat-tax states: AZ, CO, GA, ID, IL, IN, IA, KS, KY, LA, MI, MS, MO, NC, PA, UT, plus Ohio's flat 2.75% above a $26,050 zero-bracket floor.
  • 24 progressive-bracket states + DC: AL, AR, CA, CT, DE, HI, ME, MD, MA, MN, MT, NE, NJ, NM, NY, ND, OK, OR, RI, SC, VT, VA, WV, WI, DC — full published bracket tables.
  • State standard deduction (WW-127): modeled, per filing status, for the 8 jurisdictions confirmed to use rolling conformity to the current federal standard deduction — CO, ID, IA, MO (flat-rate) and MT, NM, ND, DC (progressive-bracket). All other states are still taxed on gross for the state component (TaxEstimate.StateDeductionStatus == NotModeled).
  • Local/city tax: New York City (4-bracket resident tax), Philadelphia (flat resident Wage Tax), Columbus/Cleveland/Cincinnati/Toledo/Akron (Ohio municipal income tax), St. Louis/Kansas City (Missouri earnings tax), Louisville/Lexington (Kentucky occupational license tax). Matched by exact city name (case-insensitive) + state code. Every entry carries a source (authority + URL + vintage) that the estimator turns into a SourceCitation (WW-129 T3).
  • VerifiedNone local coverage: a second curated list, verifiedNoLocalTax, names jurisdictions researched and confirmed to levy NO local income tax — the 9 no-state-income-tax states plus 23 more states with no local income-tax authority anywhere (per Tax Foundation's local-income-tax survey and, for Illinois, its own Dept. of Revenue). An entry with no city applies statewide, and (when both exist for a state) a city-specific entry takes priority over a statewide one. A city/state pair matching neither localTaxes nor verifiedNoLocalTax stays NotModeled — the Local amount is unknown, never coerced to $0. See TaxEstimate.LocalStatus below.
  • Citation attribution: each source ref carries an optional kind, naming the SourceKind its citation should use. It defaults to LocalTaxAuthority (the common case — a city/county/state government page) and is set to "Other" for a source that is evidence about a jurisdiction rather than the taxing authority itself (e.g. Tax Foundation's survey, cited for most verifiedNoLocalTax rows) — so a research aggregator is never mis-attributed as the authority it's only reporting on.

Known simplifications, stated plainly:

  • State brackets are still a single Single-filer schedule, not per-filing-status (WW-127 extended federal + FICA + a state standard deduction to all four statuses, but not state bracket thresholds — that's a much larger 50-state undertaking, deferred). For CO/ID/IA/MO (flat-rate) this doesn't matter — a single rate has no boundary for filing status to affect. For MT/NM/ND/DC (progressive, and any other state without a modeled deduction) a non-Single estimate still uses Single's bracket boundaries: TaxEstimate.StateDeductionIncomplete is true whenever this applies, even though the state standard deduction (where modeled) is still subtracted from the taxable base — a real improvement over taxing gross, just not certified complete for that status. Full state standard deduction fidelity for the remaining ~42 states is also out of MVP scope — those stay NotModeled, taxed on gross, never silently presented as complete.
  • Local/city taxes are modeled as flat-or-bracketed rates on gross, matching how most published city wage/earnings taxes actually work (Philadelphia's Wage Tax and Ohio's municipal taxes are genuinely flat-on-gross; NYC's resident tax is a real bracket table applied the same way NY state's own brackets are).
  • Local coverage today (WW-129 T3) is 11 modeled cities across 4 states (Ohio's "Big 5" minus one, NYC, Philadelphia, Missouri's two earnings-tax cities, Kentucky's two occupational-license cities) plus ~29 states with a cited VerifiedNone determination — a real but STARTING slice, not yet the epic's ~50-top-MSA target and not exhaustive: there are 5,055+ local taxing jurisdictions in the US (700+ in Ohio alone), and major metros (Detroit, Portland OR, Baltimore, Indianapolis) are still absent and stay honestly NotModeled. Extending coverage is adding a localTaxes or verifiedNoLocalTax entry to the JSON (with a citable source), no code change required. Single-owner maintenance, refreshed annually alongside the federal/state vintage (council ruling) — no automated ingestion, to keep fabrication risk at zero.
  • Local-tax rates are applied to gross using only the supplied city — none of the above distinguish work-locality from residence. Louisville's occupational license tax genuinely taxes nonresidents and residents differently; this dataset applies the resident rate uniformly (see the dataset entry's note), which can overstate a nonresident commuter's liability. Not yet modeled.
  • Maryland county piggyback taxes and Indiana's 92 county income taxes were investigated for this tier but not added: the only sources found were secondary aggregators with rates that disagreed with each other, and the authoritative PDF tables (Comptroller of Maryland, Indiana DOR Departmental Notice #1) weren't machine-readable in this environment. Left NotModeled rather than risk citing a wrong rate — a follow-up for the dataset maintainer once a reliable primary-source read is available.

TaxEstimate

public class TaxEstimate
{
    public decimal Gross { get; set; }
    public decimal Federal { get; set; }
    public decimal Fica { get; set; }
    public decimal State { get; set; }
    public decimal Local { get; set; }
    public decimal TakeHome { get; set; }       // Gross − Federal − Fica − State − Local
    public double EffectiveRate { get; set; }   // total tax / Gross
    public double MarginalRate { get; set; }    // federal + state + local marginal, summed
    public string DataVintage { get; set; }     // e.g. "2026.1"

    // Per-component provenance (WW-126 T0 contract) — populated for Local by T3 (WW-129);
    // Federal/State/Fica are filled by the T2 citation tier.
    public IReadOnlyList<SourceCitation> FederalSources { get; set; }
    public IReadOnlyList<SourceCitation> FicaSources { get; set; }
    public IReadOnlyList<SourceCitation> StateSources { get; set; }
    public IReadOnlyList<SourceCitation> LocalSources { get; set; }

    // Local-tax completeness (WW-126 T0 contract; VerifiedNone wired by T3/WW-129)
    public LocalTaxStatus LocalStatus { get; set; }             // NotModeled (0) | Modeled | VerifiedNone
    public bool LocalContributionIncomplete { get; set; }       // true unless Modeled or VerifiedNone
}

LocalTaxStatus.VerifiedNone is the only status permitted to render $0 local tax as complete — set when the city+state matches a curated verifiedNoLocalTax entry. NotModeled (the enum's zero value) still reports Local == 0m numerically, but LocalContributionIncomplete stays true and the UI must render "local income tax not modeled," never a bare $0 — the silent-zero honesty trap this tier closes.

City comparison — the seam for the financial-profile branch

CityComparisonService (src/WorkWingman.Infrastructure/Services/CityComparisonService.cs) takes two CitySalary value objects (record CitySalary(decimal Salary, string City, string State, FilingStatus FilingStatus = FilingStatus.Single)), runs each through IIncomeTaxEstimator + ICostOfLivingService, and returns:

public class CityComparisonResult
{
    public CityComparisonSide A { get; set; }   // Input, Tax, CostOfLiving
    public CityComparisonSide B { get; set; }
    public decimal RawTakeHomeDelta { get; set; }   // B.TakeHome − A.TakeHome — the one factual dollar delta
    public decimal? ColIndexRatio { get; set; }     // B index / A index — context only, null unless both sides have COL data
    public IReadOnlyList<string> HonestyCaveats { get; set; } // TakeHomeHonestyGuard disclosure, never empty
}

RawTakeHomeDelta and ColIndexRatio are two SEPARATE, never-blended figures (council ruling, WW-131) — an earlier version normalized B's take-home to A's cost-of-living baseline and diffed against A's raw take-home into one ColAdjustedTakeHomeDelta scalar; that field is deleted, not deprecated, because it read as one fact when it was really two stacked estimates. ColIndexRatio is the average of each side's RentIndex/GroceryIndex, shown as plain context next to the raw delta. It's null unless both sides have both indexes — averaging a rent-only figure on one side against a groceries-only figure on the other would compare two different, incomparable things and silently mislead the user, so a single missing index on either side withholds the ratio entirely rather than mixing partial data. HonestyCaveats (from TakeHomeHonestyGuard, mirroring DraftHonestyGuard's advisory shape) always carries the "estimate, not tax advice" disclaimer plus a fail-closed caveat whenever either side's TaxEstimate.LocalContributionIncomplete is true.

Why plain value objects: CitySalary is a record with nothing WorkWingman-specific about it — just a salary, city, state, and filing status. This is deliberate: the financial-profile branch (built in parallel) needs to feed "the user's current situation" into side A of a comparison, and that's just constructing a CitySalary from whatever the financial-profile model already holds. No service-shape changes, no new overloads — wiring it up is mechanical.

Wiring

JobPosting (src/WorkWingman.Core/Models/JobPosting.cs) carries a CostOfLiving field the same way it carries LayoffHistory:

public CostOfLivingSnapshot? CostOfLiving { get; set; }

Populated inside JobQueueService.ResyncFromLinkedInAsync, right alongside the existing layoff-history enrichment, for each newly-added job: the job's free-text Location ("City, ST") is split via ParseCityState, and if it parses cleanly, ICostOfLivingService.GetSnapshotAsync(city, state) is called and assigned. Locations that don't match the "City, ST" shape (e.g. "Remote") are skipped — CostOfLiving stays null, never a lookup against garbage input. The call site wraps the lookup in its own try/catch (defense in depth on top of CostOfLivingService's internal per-source degradation): a failure here never costs a job its already-fetched layoff history or blocks the rest of the sync.

DI registration in Program.cs follows the existing pattern — plain IServiceCollection singletons, keys read from IConfiguration:

builder.Services.AddSingleton<ICostOfLivingSource>(_ => new EiaGasSource(builder.Configuration["WorkWingman:Eia:ApiKey"]));
builder.Services.AddSingleton<ICostOfLivingSource>(_ => new BlsCpiSource(builder.Configuration["WorkWingman:Bls:ApiKey"]));
builder.Services.AddSingleton<ICostOfLivingSource, PropertyTaxSource>();
builder.Services.AddSingleton<ICostOfLivingSource, CarCostSource>();
builder.Services.AddSingleton<ICostOfLivingSource>(_ => new NumbeoSource(builder.Configuration["WorkWingman:Numbeo:ApiKey"]));
builder.Services.AddSingleton<ICostOfLivingService, CostOfLivingService>();
builder.Services.AddSingleton<IIncomeTaxEstimator, IncomeTaxEstimator>();
builder.Services.AddSingleton<ICityComparisonService, CityComparisonService>();

API

FinancesController (src/WorkWingman.Api/Controllers/FinancesController.cs):

Endpoint Method Body/Query Returns
/api/finances/cost-of-living?city=&state= GET — CostOfLivingSnapshot
/api/finances/tax-estimate POST { gross, state, city?, filingStatus? } TaxEstimate
/api/finances/compare POST { a: CitySalary, b: CitySalary } CityComparisonResult

Tests

  • IncomeTaxEstimatorTests.cs — exhaustive bracket/boundary tests (at-cap, one-dollar-over, no-tax, flat, progressive, FICA wage base + additional Medicare threshold boundaries, local tax matching, guard clauses) against a synthetic fixture dataset, plus sanity checks against the real default 2026 dataset (all 51 state entries load, known no-tax/high-tax/local-tax cases behave as expected).
  • CostOfLivingServiceTests.cs — merge-by-field, unconfigured-source-skipped, priority-on-conflict, and graceful-degradation logic against mocked ICostOfLivingSources (mirrors CompanyLayoffServiceTests).
  • CostOfLivingSourceTests.cs — config-gating behavior (IsConfigured true/false, unconfigured lookup never builds a request) for all five adapters (EIA, BLS CPI, Numbeo, PropertyTax/Census ACS, Car reference table), plus Categories population and graceful HTTP/malformed degradation. No real network in any of these.
  • CostOfLivingCategoryTests.cs — model defaults (Categories empty-not-null on a fresh snapshot; empty-string Unit/Citation/AsOf and null Value on a fresh category) and the six-member CostOfLivingCategoryKind inventory.
  • CityComparisonServiceTests.cs — raw delta math, COL-adjusted delta (full data, partial data, missing data → null, equal indexes), and COL-failure resilience, against mocked estimator/COL service.
  • JobQueueServiceTests.cs — new cases covering COL enrichment on resync, unparsable locations skipping the lookup entirely, and the call-site try/catch around a throwing ICostOfLivingService.
  • ApiSmokeTests.cs — FinancesController endpoints via WebApplicationFactory<Program> (real DI wiring, no Kestrel/browser), proving the unconfigured-by-default COL sources still return 200 with an all-null-fields snapshot.
  • Stryker.NET mutation config scoped to the new logic: stryker-config.json's mutate list adds **/Tax/*.cs (on top of the existing **/Enrichment/*.cs and **/Services/*.cs globs, which already cover the new cost-of-living sources and CityComparisonService). A scoped run (243 testable mutants after Stryker's own coverage/no-op filtering) produced a 35.59% baseline score; every surviving mutant was individually triaged and killed with a targeted test (env-var fallback paths via HttpTest, exception-message assertions, Average()-vs-Min() and ??=-vs-= distinguishing cases, and a full [Theory]/MemberData sweep of every StateRegionMap entry) or, in one case (ApplyBrackets's redundant amount <= previousCap guard), removed as genuinely dead code once traced through by hand — the surrounding amount <= cap break makes that branch unreachable under single-mutation testing. A full re-run to confirm the improved score was not completed in this session (each scoped run took ~26 minutes on this shared machine); rerun dotnet stryker -f stryker-coltax-config.json from tests/WorkWingman.Tests to confirm.