Plan: TANF Self-Employment Net Income — PAMMS 1540 Cost-of-Doing-Business Deduction
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Extend |
Done (2026-04-22) — MR !106 |
2 |
Rewrite the earned-income accumulation block in |
Done (2026-04-22) — MR !106 |
3 |
Add 7 unit tests under |
Done (2026-04-22) — MR !106 |
4 |
Add one integration test in |
Done (2026-04-22) — MR !106 |
5 |
Citations: add |
Done (2026-04-22) — MR !106 |
6 |
Roadmap sync: update |
Done (2026-04-22) — MR !106 |
Branch: feature/tanf-self-employment-net-disregard
Labels: type::bug, priority::high, program::tanf, service::tanf, compliance::pamms, workflow::ready
Context
Per ADR-011 PAMMS traceability, TANF budgeting follows the flow in PAMMS 1605 → 1615 → 1540:
-
PAMMS 1540 — treat gross self-employment income: subtract cost of doing business (Chart 1540.2: labor, stock, loan interest, insurance, property taxes, job-related transport — NOT principal, taxes, personal expenses, or depreciation). Step 3 of the procedure produces "adjusted gross self-employment income," i.e., net SE.
-
PAMMS 1615 — apply the standard $250 work-expense deduction to each employed individual’s total earned income (wages + net SE). Per PAMMS 1615 line 45: if an individual has multiple earned-income sources, combine them and deduct $250 once.
-
PAMMS 1605 — compare gross countable income to the GIC (gross-income ceiling) and net countable income to the SON (standard of need). The $250 disregard is part of the net-side calculation only.
The current canopy-tanf implementation at services/canopy-tanf/src/determine.rs:107-139 accepts three income types (wages, self_employment, self_employment_net), pools them all into per-person earned totals, and applies the $250 disregard. Step 1 of PAMMS 1540 — subtracting cost of doing business from gross SE — is skipped. The code effectively treats self_employment (gross) as if it were already-net.
Impact: any TANF applicant with self-employment income ends up with an overstated net countable income. Above the SON, the AU is denied incorrectly. Below the SON but in the benefit tail, the AU gets a smaller grant than PAMMS 1540 prescribes. This is a correctness bug, not a hack — it produces wrong numbers today.
self_employment_net (already-net) is handled correctly by the current code and does not need behaviour changes. The plan title reflects the roadmap row’s phrasing (which is the issue’s tracker name); the actual code change is centred on self_employment (gross).
Citation correction
The roadmap Tier 7 row currently cites PAMMS 1605/1611. PAMMS 1611 does not exist — the closest TANF pages are 1610 (Representative Income/Expenses) and 1615 (Earned Income Deductions). The correct citation chain for this fix is PAMMS 1540 + PAMMS 1615 + PAMMS 1605. The roadmap-update step corrects this.
Scope
In scope:
-
canopy-tanf
determine.rsearned-income accumulation logic. -
Convention:
ExpenseItemwithexpense_type = "self_employment_business_expense"as the carrier for PAMMS 1540 Chart 1540.2 allowable expenses. -
7 unit tests + 1 integration test covering the new branches.
-
1 new
citations.tomlentry + 1 updatednotesfield. -
Roadmap Tier 7 row + CHANGELOG entry.
Out of scope:
-
canopy-snap’s self-employment handling. SNAP uses a different mechanism — a 40% standard cost-of-business deduction under
[snap.self_employment].standard_deduction_pctper PAMMS 3425 / 7 CFR 273.11(a)(2). Parity review between TANF and SNAP is tracked as a separate follow-up in this plan’s Potential Improvements section. -
Schema changes to
ExpenseItem. The existing free-formexpense_type: Stringis sufficient; typed enums can follow in a crate-quality-parity pass. -
canopy-persons / canopy-applications / canopy-portal / canopy-web intake UI changes. Intake is free to emit either
self_employment(with paired expense records) orself_employment_net(pre-deducted). No UI convention is mandated here. -
Business-expense verification per PAMMS 1540 ("Verify income by using tax files, business records, receipts, bills, or statements"). Verification is a separate step (
[tanf.verification_thresholds]in jurisdiction.toml) and out of scope for the core calculation fix.
Dependencies
-
services/canopy-tanf/src/determine.rs— the accumulation block at lines 107-139. -
services/canopy-tanf/src/determine.rsExpenseItemstruct (line ~59) for the rustdoc update. -
services/canopy-tanf/tests/tanf_test.rs— integration-test file (make_tanf_contexthelper) for Step 4. -
rulesets/georgia/citations.toml—[citations."tanf.earned_income.*"]block around line 878 for thenotesupdate and the newcost_of_doing_business_methodentry. -
docs/modules/ROOT/pages/roadmap.adocTier 7 (around line 796 at time of writing) for the row update. -
.claude/docs/services.md— canopy-tanf section for the convention note. -
CHANGELOG.adoc—== Unreleased/=== Fixed.
No schema migrations, no event-payload changes, no service-to-service contract changes.
Design
Income-aggregation flow (after fix)
// services/canopy-tanf/src/determine.rs (replaces lines 107-139)
// 1. Build per-person earned-income totals by type.
let mut wages_by_person: HashMap<Uuid, Decimal> = HashMap::new();
let mut gross_se_by_person: HashMap<Uuid, Decimal> = HashMap::new();
let mut net_se_by_person: HashMap<Uuid, Decimal> = HashMap::new();
let mut gross_income = Decimal::ZERO;
for item in &ctx.income {
store::create_income(
db,
tanf_app.id,
item.person_id,
&item.income_type,
item.monthly_amount,
"monthly",
"self_report",
)
.await
.map_err(|e| ApiError::internal("create income", e))?;
gross_income += item.monthly_amount;
match item.income_type.as_str() {
"wages" => {
*wages_by_person.entry(item.person_id).or_insert(Decimal::ZERO) += item.monthly_amount;
}
"self_employment" => {
*gross_se_by_person
.entry(item.person_id)
.or_insert(Decimal::ZERO) += item.monthly_amount;
}
"self_employment_net" => {
*net_se_by_person
.entry(item.person_id)
.or_insert(Decimal::ZERO) += item.monthly_amount;
}
_ => {} // unearned — not pooled for PAMMS 1615 disregard
}
}
// 2. PAMMS 1540 Step 3 — subtract business expenses from each person's gross SE.
let mut business_expenses_by_person: HashMap<Uuid, Decimal> = HashMap::new();
for expense in &ctx.expenses {
if expense.expense_type == "self_employment_business_expense" {
*business_expenses_by_person
.entry(expense.person_id)
.or_insert(Decimal::ZERO) += expense.monthly_amount;
}
}
// 3. Sum per-person earned income and apply PAMMS 1615 disregard per individual.
let flat_disregard = params.earned_income_disregard();
let mut earners: HashSet<Uuid> = HashSet::new();
earners.extend(wages_by_person.keys().copied());
earners.extend(gross_se_by_person.keys().copied());
earners.extend(net_se_by_person.keys().copied());
let total_disregard: Decimal = earners
.iter()
.map(|pid| {
let wages = wages_by_person.get(pid).copied().unwrap_or(Decimal::ZERO);
let gross_se = gross_se_by_person.get(pid).copied().unwrap_or(Decimal::ZERO);
let net_se_from_gross = (gross_se
- business_expenses_by_person
.get(pid)
.copied()
.unwrap_or(Decimal::ZERO))
.max(Decimal::ZERO);
let pre_declared_net_se = net_se_by_person.get(pid).copied().unwrap_or(Decimal::ZERO);
let earned = wages + net_se_from_gross + pre_declared_net_se;
earned.min(flat_disregard)
})
.sum();
let net_income = (gross_income - total_disregard).max(Decimal::ZERO);
Note that ExpenseItem doesn’t currently carry person_id in the struct shown at determine.rs:59 — verify before implementation. If it carries only expense_type + monthly_amount at the AU level, the implementer has two options:
-
Add
person_id: Option<Uuid>toExpenseItem(non-breaking for existing callers via#[serde(default)]). -
Apply the pooled business-expense total against the pooled gross-SE total at the AU level (one disregard per AU rather than per person for the SE component).
Option 1 matches PAMMS 1540’s per-person semantics and is the recommended path. If ExpenseItem is missing person_id, Step 1 of this plan grows a #[serde(default)] addition.
Gross-income-ceiling side
gross_income continues to sum all item.monthly_amount values regardless of type, matching PAMMS 1605’s gross-countable-income definition. Business expenses are not subtracted from gross_income — only from the net-side earned-income pool. This preserves the GIC vs. SON distinction.
Multi-earner behaviour
PAMMS 1615 line 45 explicitly requires combining multiple earned-income sources per individual and deducting $250 once. The per-person_id aggregation in the rewritten logic preserves this. A two-adult AU with both earning wages gets two $250 disregards (one per employed individual) — exercised by the multi_earner_household_gets_disregard_per_person test.
Test discipline — no hardcoded regulatory values in test assertions
Per ADR-011, no regulatory value (dollar amount, percentage, threshold) may be hardcoded in source — including tests. The $250 disregard lives in jurisdiction.toml and flows through params.earned_income_disregard(). Tests must preserve this contract:
Required:
-
Each unit test obtains a real
TanfParamsTablevia the existinggeorgia_table()helper atservices/canopy-tanf/src/params.rs:330(which loads from the fixture/real jurisdiction.toml), or constructs aTanfParamsTablewith an explicitly-named test disregard and asserts relative to that binding. -
Expected outputs are computed inline from
params.earned_income_disregard(), not written as bare literals. -
If jurisdiction.toml changes the disregard amount tomorrow, these tests must still pass without edits. That’s the portability invariant.
Example (approved pattern):
#[test]
fn self_employment_gross_with_expenses_deducts_before_disregard() {
let params = georgia_table();
let disregard = params.earned_income_disregard();
// gross SE $1000, business expenses $400 → net SE = $600
// earned = $600; disregard_applied = min($600, $disregard)
let gross_se = Decimal::from(1000);
let expenses = Decimal::from(400);
let net_se = (gross_se - expenses).max(Decimal::ZERO); // $600
let expected_disregard = net_se.min(disregard);
let expected_earned_after_disregard = net_se - expected_disregard;
// ... run determine logic, assert result == expected_earned_after_disregard
}
Disallowed pattern:
// ❌ hardcodes $250 by writing $750 as the expected output
assert_eq!(result.earned_after_disregard, Decimal::from(750));
The one exception is the existing earned_income_disregard_is_250 regression test at params.rs:348, which intentionally asserts the loaded Georgia value against Decimal::from(250) — that test is the jurisdiction-config contract for Georgia, not a determination-logic test. New tests added by this plan are determination-logic tests and must follow the Required rules above.
The Step 4 integration test follows the same discipline: it reads the real jurisdiction.toml through the service’s params loader and asserts relative to params.earned_income_disregard().
The PAMMS-1540-example plan-level verification (gross SE $1000, expenses $400, wages $300; expected earned = (600 + 300) − $250 = $650) in Verification is a human sanity-check, not a test assertion — it’s expressed with the Georgia value so reviewers can verify the math by hand.
Files Touched
| Category | Files |
|---|---|
Core logic |
|
Unit tests |
|
Integration test |
|
Citations |
|
Convention docs |
|
Roadmap |
|
Changelog |
|
No migrations, no HTTP API shape changes, no event-payload changes.
Verification
Per-step verification
-
cargo nextest run -p canopy-tanf— new unit tests pass; existing 73 canopy-tanf tests remain green. -
cargo nextest run -p canopy-tanf --test tanf_test — self_employment— new integration test passes. -
cargo xtask policy audit— green (new citation entry conforms). -
cargo xtask rules check— green (no JDM changes). -
cargo xtask validate— full battery green (fmt + clippy + nextest + docker build). -
Pre-push hook (
git config core.hooksPath .githooks) runs validate automatically on push.
Plan-level verification
-
PAMMS 1540 worked example — manually compute for a canonical case (gross SE $1000, expenses $400, wages $300; expected earned = (600 + 300) − $250 = $650) and assert against code output.
-
Regression — re-run the existing
canopy-tanftest suite; no existing tests change their expectations (current behaviour forwagesandself_employment_netis preserved). -
Roadmap Tier 7 row points at the new plan with corrected PAMMS citations.
Documentation Updates
-
CHANGELOG.adoc— new bullet under== Unreleased/=== Fixeddescribing the correctness bug (overstatement of TANF net countable income whenself_employmentgross records weren’t paired with paired expense deduction) and its resolution. -
roadmap.adocTier 7 — update status + correct the PAMMS citation typo. -
.claude/docs/services.md— add a canopy-tanf subsection noting theself_employment_business_expenseexpense-type convention for PAMMS 1540 compliance. -
tanf-pamms-alignment.adocErrata — optional cross-reference added pointing at this plan as the formal home of the SE-net fix that was referenced in passing but not actually scoped there.
Potential Improvements
Out of scope for this plan but worth capturing:
-
SNAP self-employment parity review. SNAP uses
snap.self_employment.standard_deduction_pct = 40per PAMMS 3425 / 7 CFR 273.11(a)(2). Confirm that canopy-snap’sdetermine.rs:424earned-income match handlesself_employmentvsself_employment_netconsistently with SNAP’s 40% standard deduction rule. Likely a follow-up plan titledsnap-self-employment-standard-deduction.adoc. -
Typed
expense_typeenum.ExpenseItem.expense_type: Stringis a stringly-typed interface. A typed enum (with aSelfEmploymentBusinessExpensevariant) would prevent typos at the intake boundary and let clippy’smissing_variantlint catch future-type gaps. Tracked under the broader crate-quality-parity work. -
ExpenseItem.person_id requirement. If Step 1 finds
ExpenseItemlacksperson_id, treat adding it as required for this fix; otherwise capture as a Potential Improvement for a later pass if the AU-level fallback is accepted. -
Ruleset-side verification threshold. PAMMS 1540 Chart 1540.2 enumerates allowable vs unallowable expenses. A future ruleset-side lint could enforce the allowlist at intake time rather than trusting callers. Out of scope — depends on ruleset-first validation patterns not yet in place.
Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):
-
#323 — Typed expense_type enum (from Potential Improvements)
Tracked follow-ups (filed 2026-05-04 during PI sweep):
-
#414 — SNAP self-employment standard deduction parity (PAMMS 3425)
-
Ruleset-side verification threshold (PAMMS 1540 Chart 1540.2 allowlist) — depends on ruleset-first validation patterns not yet established; revisit when those patterns land. Deferred indefinitely.
Errata
2026-04-21 — gross_income calculation corrected to PAMMS 1540 Step 4 semantics
The plan’s original Design section (Status row 2 sub-bullet e, and the "Gross-income-ceiling side" Design subsection) claimed the existing gross_income aggregation would be preserved — i.e., business-expense deduction would only affect the net-side disregard pool, not the gross-income-ceiling input. This was wrong.
Re-reading PAMMS 1540 during Step 2 implementation:
-
"Basic Considerations" line 14 of the PAMMS 1540 module: "The amount of income budgeted is determined by using the total gross receipts plus capital gains, if any, less business expenses (the cost of doing business)."
-
"Procedures" Step 3-4: "Subtract the cost of doing business. The result is the adjusted gross self-employment income. […] Calculate deductions and benefit level as for any other AU. Refer to Chapter 1600, Eligibility Budgeting."
That is: the adjusted gross SE income (gross receipts minus cost of doing business) IS the figure that enters Chapter 1600 / PAMMS 1605 budgeting. The PAMMS 1615 $250 disregard is a further deduction applied on top. Pre-COB raw gross receipts never enter the AU’s gross countable income.
Corrected implementation: compute_tanf_earned_income now accumulates non-SE income (wages, self_employment_net, unearned) directly into gross_income, computes each person’s adjusted-gross SE as max(raw_gross - business_expenses, 0) first, then adds the summed adjusted-gross-SE to gross_income. This value feeds both the PAMMS 1605 GIC comparison and the PAMMS 1615 disregard pool.
Impact on tests:
-
self_employment_gross_with_expenses_deducts_before_disregard— now assertsresult.gross_income == expected_adjusted_gross_se(not raw gross). -
self_employment_gross_expenses_exceed_gross_floors_at_zero— now assertsresult.gross_income == 0(expenses exceeded raw gross, so adjusted gross floors at zero). -
Integration-test scenario revised —
$1000raw gross /$500business expense / adjusted gross$500under the HH=3 GIC$784. With the pre-fix code path the raw$1000would deny on the GIC check; with the fix it approves cleanly. The test is an effective regression marker for both layers of the PAMMS 1540 fix (net-side deduction + gross-side adjustment).
Plan text in Design sub-bullet (e) and the Gross-Income-Ceiling subsection is intentionally left as-is in this Errata-driven record so reviewers can see the deviation; downstream readers should treat this Errata entry as authoritative.