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.

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 COL-adjusted comparison 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 any resulting ColAdjustedTakeHomeDelta 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<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.

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.1" 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 single-filer standard deduction ($16,100), 2026 figures per IRS inflation adjustments (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 $200,000 (single).
  • State: all 50 states + DC, single-filer brackets:
  • 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, single filer.
  • 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). Matched by exact city name (case-insensitive) + state code.

Known simplifications, stated plainly:

  • State brackets are applied to gross salary directly — the dataset doesn't track each state's own standard deduction/personal exemption separately from the federal one. This slightly overstates state tax for states with a meaningful standard deduction.
  • 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).
  • Only FilingStatus.Single has data today. The enum exists so married/head-of-household support is a data-and-branch addition later, not a breaking interface change.
  • Local coverage is a representative sample (the cities named in the original spec), not exhaustive — there are 700+ taxing municipalities in Ohio alone. Extending coverage is adding a localTaxes entry to the JSON, no code change required.

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"
}

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
    public decimal? ColAdjustedTakeHomeDelta { get; set; } // null unless both sides have COL data
}

ColAdjustedTakeHomeDelta normalizes B's take-home to A's cost-of-living baseline using the average of each side's RentIndex/GroceryIndex, then compares. 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 adjustment entirely rather than mixing partial data.

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>(_ => 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 three adapters. No real network in any of these.
  • 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.csFinancesController 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.