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 ACSB25103_001E), unitUSD/year. - Car — from
CarCostSource(bundled insurance + registration reference table), unitUSD/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 aSourceCitation(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 neitherlocalTaxesnorverifiedNoLocalTaxstaysNotModeled— the Local amount is unknown, never coerced to$0. SeeTaxEstimate.LocalStatusbelow. - Citation attribution: each
sourceref carries an optionalkind, naming theSourceKindits citation should use. It defaults toLocalTaxAuthority(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 mostverifiedNoLocalTaxrows) — 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.StateDeductionIncompleteistruewhenever 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 stayNotModeled, 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
VerifiedNonedetermination — 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 honestlyNotModeled. Extending coverage is adding alocalTaxesorverifiedNoLocalTaxentry to the JSON (with a citablesource), 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
NotModeledrather 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 mockedICostOfLivingSources (mirrorsCompanyLayoffServiceTests).CostOfLivingSourceTests.cs— config-gating behavior (IsConfiguredtrue/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 (Categoriesempty-not-null on a fresh snapshot; empty-string Unit/Citation/AsOf and nullValueon a fresh category) and the six-memberCostOfLivingCategoryKindinventory.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