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.Singlehas 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
localTaxesentry 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 mockedICostOfLivingSources (mirrorsCompanyLayoffServiceTests).CostOfLivingSourceTests.cs— config-gating behavior (IsConfiguredtrue/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 throwingICostOfLivingService.ApiSmokeTests.cs—FinancesControllerendpoints viaWebApplicationFactory<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'smutatelist adds**/Tax/*.cs(on top of the existing**/Enrichment/*.csand**/Services/*.csglobs, which already cover the new cost-of-living sources andCityComparisonService). 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]/MemberDatasweep of everyStateRegionMapentry) or, in one case (ApplyBrackets's redundantamount <= previousCapguard), removed as genuinely dead code once traced through by hand — the surroundingamount <= capbreak 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); rerundotnet stryker -f stryker-coltax-config.jsonfromtests/WorkWingman.Teststo confirm.
Related docs¶
- Plain-language version: ../plain/col-tax.md
- Job signals (the layoff-history sibling feature, same graceful-degradation philosophy): job-signals.md
- Testing philosophy and tooling: testing.md