Plan: TANF Self-Employment Net Income — PAMMS 1540 Cost-of-Doing-Business Deduction

On this page

Status

Step Description Status

1

Extend canopy-tanf’s `ApplicationContext convention: an ExpenseItem with expense_type = "self_employment_business_expense" represents a PAMMS 1540 Chart 1540.2 allowable-expense record for the person that owns the matching self_employment income record. Document this in the ExpenseItem struct rustdoc and in .claude/docs/services.md’s canopy-tanf section. No schema change — `ExpenseItem.expense_type is already a free-form string.

Done (2026-04-22) — MR !106

2

Rewrite the earned-income accumulation block in services/canopy-tanf/src/determine.rs:107-139 so it: (a) iterates ctx.income once, splitting earned records into per-person totals by type (wages, self_employment, self_employment_net); (b) for each person with any self_employment (gross) record, looks up ctx.expenses entries with expense_type == "self_employment_business_expense" and matching person_id, sums them, and computes net_se = max(gross_se − business_expenses, 0) (PAMMS 1540 Step 3); (c) builds each person’s earned_income = wages + net_se + self_employment_net; (d) applies the $250 flat disregard per employed individual via min(earned_income, flat_disregard) summed across persons (PAMMS 1615; semantics unchanged); (e) preserves the existing gross_income aggregation (no change to gross-income-ceiling math — the GIC compares against gross countable income per PAMMS 1605, and business-expense deduction is a step in the net side only).

Done (2026-04-22) — MR !106

3

Add 7 unit tests under services/canopy-tanf/src/determine.rs mod tests (new block if one doesn’t exist) using in-process ApplicationContext values — no DB required. All assertions must read the disregard from params.earned_income_disregard(), not hardcode $750/$500/$250 literals — see Test discipline for the required pattern and the disallowed anti-pattern: * wages_only_applies_disregard_once — single wage-earner, $1000/mo, expected earned = $750 after $250 disregard * self_employment_net_only_applies_disregard_once — single net-SE earner, $1000/mo, expected earned = $750 * self_employment_gross_with_expenses_deducts_before_disregard — gross SE $1000, business expenses $400, expected net SE = $600, expected earned = $350 * self_employment_gross_without_expenses_uses_full_gross — gross SE $1000, no expenses, expected net SE = $1000, expected earned = $750 (documents the "no expenses on file" fallback) * self_employment_gross_expenses_exceed_gross_floors_at_zero — gross SE $400, expenses $1000, expected net SE = $0, expected earned = $0 * mixed_wages_plus_net_se_combines_per_person — one person with $600 wages + $400 SE-net, expected earned = $750 (single $250 disregard per employed individual per PAMMS 1615 line 45) * multi_earner_household_gets_disregard_per_person — two adults each with $500 wages, expected earned = $500 (two $250 disregards)

Done (2026-04-22) — MR !106

4

Add one integration test in services/canopy-tanf/tests/tanf_test.rs that POSTs a determine request with a gross self_employment record + paired business-expense record and asserts the resulting TanfDetermination.net_income reflects the post-expense + post-disregard value. Follow the pattern of existing tests in that file.

Done (2026-04-22) — MR !106

5

Citations: add [citations."tanf.self_employment.cost_of_doing_business_method"] to rulesets/georgia/citations.toml pointing at dfcs-tanf/modules/tanf/pages/1540.adoc Chart 1540.2 with authority = "pamms". Update the existing [citations."tanf.earned_income.disregard_amount_cents"] entry’s notes to explicitly name PAMMS 1540 Step 4 as the pathway from adjusted gross SE income into the disregard pool.

Done (2026-04-22) — MR !106

6

Roadmap sync: update docs/modules/ROOT/pages/roadmap.adoc Tier 7 SelfEmploymentNet disregard row — replace the stale tanf-pamms-alignment plan reference with tanf-self-employment-net-disregard, correct the citation typo (PAMMS 1605/1611PAMMS 1540/1615), and mark status Done with the date. Also add a CHANGELOG entry under == Unreleased / === Fixed documenting the correctness bug and its resolution.

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:

  1. 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.

  2. 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.

  3. 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.rs earned-income accumulation logic.

  • Convention: ExpenseItem with expense_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.toml entry + 1 updated notes field.

  • 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_pct per 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-form expense_type: String is 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) or self_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.rs ExpenseItem struct (line ~59) for the rustdoc update.

  • services/canopy-tanf/tests/tanf_test.rs — integration-test file (make_tanf_context helper) for Step 4.

  • rulesets/georgia/citations.toml[citations."tanf.earned_income.*"] block around line 878 for the notes update and the new cost_of_doing_business_method entry.

  • docs/modules/ROOT/pages/roadmap.adoc Tier 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:

  1. Add person_id: Option<Uuid> to ExpenseItem (non-breaking for existing callers via #[serde(default)]).

  2. 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:

  1. Each unit test obtains a real TanfParamsTable via the existing georgia_table() helper at services/canopy-tanf/src/params.rs:330 (which loads from the fixture/real jurisdiction.toml), or constructs a TanfParamsTable with an explicitly-named test disregard and asserts relative to that binding.

  2. Expected outputs are computed inline from params.earned_income_disregard(), not written as bare literals.

  3. 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

services/canopy-tanf/src/determine.rs (earned-income accumulation + ExpenseItem rustdoc; possibly ExpenseItem.person_id addition)

Unit tests

services/canopy-tanf/src/determine.rs mod tests

Integration test

services/canopy-tanf/tests/tanf_test.rs

Citations

rulesets/georgia/citations.toml

Convention docs

.claude/docs/services.md (canopy-tanf section)

Roadmap

docs/modules/ROOT/pages/roadmap.adoc (Tier 7 row)

Changelog

CHANGELOG.adoc (=== Fixed under == Unreleased)

No migrations, no HTTP API shape changes, no event-payload changes.

Verification

Per-step verification

  1. cargo nextest run -p canopy-tanf — new unit tests pass; existing 73 canopy-tanf tests remain green.

  2. cargo nextest run -p canopy-tanf --test tanf_test — self_employment — new integration test passes.

  3. cargo xtask policy audit — green (new citation entry conforms).

  4. cargo xtask rules check — green (no JDM changes).

  5. cargo xtask validate — full battery green (fmt + clippy + nextest + docker build).

  6. Pre-push hook (git config core.hooksPath .githooks) runs validate automatically on push.

Plan-level verification

  1. 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.

  2. Regression — re-run the existing canopy-tanf test suite; no existing tests change their expectations (current behaviour for wages and self_employment_net is preserved).

  3. Roadmap Tier 7 row points at the new plan with corrected PAMMS citations.

Documentation Updates

  • CHANGELOG.adoc — new bullet under == Unreleased / === Fixed describing the correctness bug (overstatement of TANF net countable income when self_employment gross records weren’t paired with paired expense deduction) and its resolution.

  • roadmap.adoc Tier 7 — update status + correct the PAMMS citation typo.

  • .claude/docs/services.md — add a canopy-tanf subsection noting the self_employment_business_expense expense-type convention for PAMMS 1540 compliance.

  • tanf-pamms-alignment.adoc Errata — 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 = 40 per PAMMS 3425 / 7 CFR 273.11(a)(2). Confirm that canopy-snap’s determine.rs:424 earned-income match handles self_employment vs self_employment_net consistently with SNAP’s 40% standard deduction rule. Likely a follow-up plan titled snap-self-employment-standard-deduction.adoc.

  • Typed expense_type enum. ExpenseItem.expense_type: String is a stringly-typed interface. A typed enum (with a SelfEmploymentBusinessExpense variant) would prevent typos at the intake boundary and let clippy’s missing_variant lint catch future-type gaps. Tracked under the broader crate-quality-parity work.

  • ExpenseItem.person_id requirement. If Step 1 finds ExpenseItem lacks person_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 asserts result.gross_income == expected_adjusted_gross_se (not raw gross).

  • self_employment_gross_expenses_exceed_gross_floors_at_zero — now asserts result.gross_income == 0 (expenses exceeded raw gross, so adjusted gross floors at zero).

  • Integration-test scenario revised — $1000 raw gross / $500 business expense / adjusted gross $500 under the HH=3 GIC $784. With the pre-fix code path the raw $1000 would 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.

Edit this page · default