Wave 2 Demo Walkthrough

What we built, how it plugs into the NLZ pipeline, and what the demo will show — in plain English.

Executive summary. Wave 1 proved that a single withdrawal and a single IRA contribution could travel end-to-end through cam-movemoney-process-api. Wave 2 turns the POC into a representative slice of the real NLZ Move Money stack. The eligibility gate now accepts 32 account codes instead of 3. A new six-service rule layer sits inline between eligibility and compliance, enforcing IRS contribution rules at the moment a request arrives. The mock layer serves 60 accounts with realistic IRA data so we can actually trigger and observe each rule service rejecting or passing a request live. Everything below is what the demo will cover.
3
Rule services exercised live
3
Rule services wired but stubbed
60
Mock accounts across 30 class codes
32
Account codes now accepted (up from 3)

1. What changed since Wave 1

Wave 1 — proof of concept
  • One brokerage withdrawal path (account BIN)
  • One traditional IRA contribution path (account TA)
  • Eligibility gate accepts 3 codes (BBK, BFL, BMM)
  • No inline retirement rules; pipeline stops at eligibility
  • Mock layer has 2 accounts
Wave 2 — coverage + logic (this wave)
  • Eligibility widened to 32 account codes via config
  • Six new inline rule services between eligibility and compliance
  • Contribution metadata sidecar on every request (year, type, rollover, conversion, HSA coverage, YTD)
  • Three new endpoints: POST /ach/contribution, POST /ach/withdrawal/with-reason, DELETE /ach/queued/{txId}
  • 60-account mock with realistic IRA data, 100 prior contributions, 50 prior withdrawals, 2026 IRS limit table, per-account YTD aggregations
  • End-to-end pipeline runs with live rule rejection and live success path
Wave 3 — deferred
  • Recurring / periodic contributions (needs background scheduler)
  • Future-dated deposit (needs scheduler)
  • Tax withholding service (fed/state calc)
  • Real 5498 / 1099-R emission to Tax Ops
  • Bridge BFF routing (investor-cash-mgmt-process-api)
  • SCA channel parity

2. The pipeline, step by step

The orchestrator is AvAchService in cam-movemoney-process-api/api/2-Domain/cam.movemoney.process.api.Business/Implementations/AvAchService.cs. Every ACH request flows through these steps. Wave 2 inserts a new step between eligibility and compliance.

StepWhat it doesWave 2 change
1. Parallel fetchCalls account details, restrictions, transfers, and BETA instructions simultaneously.Now reads from 60-account mock; parallel-complete logs confirm every hop.
2. Validate AV detailsConfirms account id matches the lpl account number returned by the BETA instruction.None.
3. DB insertWrites the transaction skeleton into the movemoney Postgres.None.
4. Notify (fire and forget)Queues a notification; short-circuited in POC.None.
5. RTT submittedMarks the real-time-tracking record as submitted; short-circuited in POC.None.
6. EligibilityServiceRuns amount validation, account class code, BORD restrictions, house account, business eligibility, and high-dollar rules.Widened from 3 to 32 accepted account codes via EligibilityPolicy:ValidClassCodes config.
7. ApplyContributionRules (NEW)Runs the six Wave 2 inline rule services in order. Any failure shorts to NIGO with a specific rejection message.Net-new step. Invoked at AvAchService.cs line 273 (right after EligibilityService at line 248, before ComplianceService at line 292).
8. ComplianceServiceExisting inline compliance checks.None.
9. Approved + auditWrites approval audit record.None.
10. Completed + RTT + KafkaFinalises the RTT record and emits the Kafka event; short-circuited in POC.None.
Where the Wave 2 rules live. All six rule services are registered in IocContainerConfiguration.cs and invoked inside ApplyContributionRules in AvAchService.cs around lines 273–289. They read the account details and the new ContributionMetadata sidecar that rides alongside each request. When any rule returns a fail result, the pipeline stops, writes a NIGO RTT update, and returns a structured 422 response with the specific rule name and message.

3. The six new rule services — what each one does

Each card below describes one service in plain English, the exact rule it enforces, and the rejection message pulled directly from the service source code. Three of them are exercised live in the smoke tests in section 4. The other three are wired end-to-end but their primary path isn't yet triggered by a smoke test — the logic is present and the pipeline invokes them; they just need a payload variant to demonstrate rejection.

ContributionLimitService ✓ Live

What it does: when a contribution arrives, reads the 2026 IRS limit table and the client’s year-to-date contribution total, then refuses to accept anything that would push the total above the limit.

How it picks the limit: accounts starting with H get the HSA limit (family if HDHP-covered, single otherwise, plus the 55+ catch-up). SepEmployer/SepSelf metadata gets the SEP max. SimpleEmployee/SimpleEmployer metadata gets SIMPLE plus both catch-ups. Everything else starting with Q or R gets the IRA base plus the 50+ catch-up.

Rejection message:

Projected contributions {amount} exceed IRS limit {limit} for {classCode} {year}

Source: ContributionLimitService.cs:62

ContributionYearService ✓ Live

What it does: enforces the IRS prior-year contribution window. A contribution tagged for the prior tax year is only valid between Jan 1 and Apr 15 of the current year. Current-year contributions always pass; future-year contributions always fail.

Where it fires: every contribution request that carries a contribution_year in metadata.

Rejection messages:

Prior-year {year} contribution window closed (after Apr 15)
Future-year contribution not allowed ({year})
Contribution year {year} is too far in the past

Source: ContributionYearService.cs:33–48

HsaEligibilityService ✓ Live

What it does: stops any HSA contribution (accounts starting with H) unless the accountholder has HDHP coverage marked in metadata. Non-HSA accounts pass through untouched.

The rule: if classCode starts with H and metadata.IsHdhpCovered is false, fail.

Rejection message:

HSA contribution requires HDHP coverage

Source: HsaEligibilityService.cs:32

RolloverRuleService ⚠ Stubbed

What it does: when a rollover metadata tag is present, assigns the correct 1099-R code and (for indirect rollovers) logs the intent to check the once-per-12-months IRS rule.

1099-R code map: DirectTrustee → G, Indirect60Day → 7, Roth401kToRothIra → H, IraToIra → G, Spousal → G, Inherited → 4.

Stub note: the 12-month lookup against transaction history is logged but always returns pass. Wiring to real history is a Wave 3 item.

Source: RolloverRuleService.cs. To trigger: send rollover_type: "Indirect60Day" in contribution metadata.

ConversionService ⚠ Stubbed

What it does: when a Traditional-to-Roth conversion is flagged, tags the source account with a pending 1099-R (code 2 for early, 7 for normal) and the destination account with a pending 5498 Box 3.

Stub note: logs the tagging intent but does not yet persist to the system-api. Real ledger updates are a Wave 3 item (needs Tax Ops integration for 5498 emission).

Result tag:

Conversion {type} tagged for 1099-R/5498

Source: ConversionService.cs:32. To trigger: send contribution_type: "Conversion" in metadata.

ExcessContributionService ⚠ Stubbed

What it does: when the combined YTD plus new amount exceeds the IRS limit, flags the excess and distinguishes between pre-deadline (withdraw excess + earnings, no 6% excise) and post-deadline (6% excise tax applies).

Phase determination: compares current time against Apr 15 of the year after the contribution year.

Rejection message:

Excess contribution of {amount} detected ({phase})

Source: ExcessContributionService.cs:61. In practice ContributionLimitService rejects the same condition first with a friendlier message; this service is the persistence-and-reporting companion.

4. Live evidence — we actually exercised the pipeline

All five tests below ran against the local stack on 2026-04-24. Every response shown is the actual bytes returned by the running pipeline. Source: ~/code/ach-poc/WAVE2-LIVE-RULE-EXECUTION.md.

4.1 ContributionLimitService rejects $20,000 Roth IRA with catch-up detection

We send a $20,000 Roth IRA contribution for account 10000024 (age 65 in the mock fixture). The IRS 2026 Roth IRA limit is $7,000 plus a $1,000 catch-up for 50+ = $8,000.

curl -X POST http://localhost:5001/ach/contribution -H 'Content-Type: application/json' -d '{
  "transaction_request": {
    "account_id": 10000024, "lpl_account_number": "10000024",
    "amount": "20000.00", "firm_id": 1, "transaction_type": "ACHC",
    "origin_id": "AccountView", "beta_item_seq_no": "1",
    "requested_execution_date": "2026-04-24T12:00:00Z",
    "rep_id": "ABC1", "client_id": 1024,
    "transfer_reason": "IRA Contribution", "transfer_type": "contributionType",
    "user_context": {"advisor_user_name": "TestAdvisor"}
  },
  "contribution_metadata": {
    "contribution_year": 2026, "contribution_type": "Regular",
    "actor_type": "Investor", "ytd_contributions_amount": 0
  }
}'

Actual response:

{
  "status": "Fail", "status_code": 422,
  "data": {
    "error_message": {
      "type": "ContributionLimit",
      "code": 422,
      "errors": [{
        "message": "Projected contributions $20,000.00 exceed IRS limit $8,000.00 for RRH 2026"
      }]
    }
  }
}

Takeaway: the service correctly included the 50+ catch-up when computing the $8,000 limit, then rejected with the specific class code and year in the message.

4.2 Happy path — $3,000 Roth contribution completes

Same payload, amount "3000.00". The pipeline now runs all six rule services, passes each, advances through compliance, and short-circuits downstream BETA/RTT/Kafka because LocalPoc:ShortCircuitDownstream is on.

Actual response:

{
  "status": "Success", "status_code": 200,
  "data": {
    "scheduled_execution_date": "2026-04-27",
    "cm_request_id": "a697e969-f2ba-4426-8d76-b951b07afcde"
  }
}

Takeaway: full pipeline completion. Every rule passed and the orchestrator reached the Kafka-emit step (where the POC intentionally shorts).

4.3 HsaEligibilityService rejects HSA contribution without HDHP

Account 10000047 (HMM HSA, IsHdhpCovered=false in the mock), $1,000 contribution, metadata is_hdhp_covered: false.

Actual response:

{
  "status": "Fail", "status_code": 422,
  "data": {
    "error_message": {
      "type": "HsaEligibility",
      "code": 422,
      "errors": [{ "message": "HSA contribution requires HDHP coverage" }]
    }
  }
}

Takeaway: the rule fires only for accounts starting with H, and correctly gates on the HDHP metadata flag.

4.4 ContributionYearService rejects stale prior-year

Same IRA payload, contribution_year: 2025. Today is 2026-04-24, past the Apr 15 prior-year window.

Actual response:

{
  "status": "Fail", "status_code": 422,
  "data": {
    "error_message": {
      "type": "ContributionYear",
      "code": 422,
      "errors": [{ "message": "Prior-year 2025 contribution window closed (after Apr 15)" }]
    }
  }
}

Takeaway: the service enforces the exact IRS window (Jan 1 – Apr 15) and returns a clear, human-readable reason.

4.5 Withdrawal with RMD reason completes

Using the new POST /ach/withdrawal/with-reason endpoint with transfer_reason: "RMD", amount $5,000 from Roth IRA 10000024.

Actual response:

{
  "status": "Success", "status_code": 200,
  "data": {
    "scheduled_execution_date": "2026-04-27",
    "cm_request_id": "b2326ec5-3f83-4a96-b6fb-6f2a3f50c618"
  }
}

Takeaway: the TransferReason path is operational. UC-W2 (reason-coded withdrawals) is implementable against this endpoint.

5. The three new endpoints we added

Method + PathPurposeWave 2 service driving it
POST /ach/contributionAccepts an ACH contribution plus a contribution-metadata sidecar (year, type, rollover, conversion, HSA coverage, YTD).All six rule services; ContributionLimit, ContributionYear, and HsaEligibility proven live.
POST /ach/withdrawal/with-reasonAccepts an ACH withdrawal plus a TransferReason (RMD, ExcessReturn, Other) so downstream reporting can classify the distribution.Feeds UC-W2 (reason-coded withdrawals).
DELETE /ach/queued/{txId}UC-W4 cancel-a-queued-withdrawal stub. No persistent queue in POC; returns a structured acknowledgment so the contract is in place for Wave 3.Stub; returns {"transaction_id":"...","status":"canceled-stub"}.

Sources: AchController.cs lines 378 (contribution), 419 (withdrawal-with-reason), 459 (delete-queued).

6. The demo script — 5 minutes flat

Open two browser tabs: the static site at https://mandos.team/achwithdrawls/ and a terminal with the local stack running (~/code/ach-poc/start-all.ps1). Follow these steps.

  1. (30s) Open the site. “This is the executive surface for the POC. Fourteen pages covering use cases, architecture, gaps, CAD / SD / SyDD, and today’s implementation status.” [Click through to Implementation Status to show the 37-implemented count.]
  2. (45s) Set the stage. “Wave 1 proved the plumbing end-to-end for one path. Wave 2 widens eligibility to 32 account codes and adds a six-service rule layer between eligibility and compliance. I’ll show three rule services rejecting bad requests live, and the happy path completing.”
  3. (45s) Over-limit rejection (hero moment). “I’m sending a $20,000 Roth IRA contribution for a 65-year-old investor. IRS 2026 limit is $7,000 plus $1,000 catch-up. Watch what the new ContributionLimitService does.” [Run the cURL from section 4.1.] “Notice the response says Projected contributions $20,000 exceed IRS limit $8,000 for RRH 2026. The service read the age from the mock and added the catch-up automatically.”
  4. (45s) Happy path. “Drop the amount to $3,000 and the exact same pipeline runs, passes every rule, and returns a scheduled execution date.” [Run the section 4.2 cURL.] “Everything after the rule layer is short-circuited for the POC, but the orchestrator ran all ten steps.”
  5. (45s) HSA without HDHP. “Different rule, different account. HSA contribution for an account where the mock says the holder is not HDHP-covered.” [Run section 4.3 cURL.] “HsaEligibilityService fires, pipeline halts with a specific reason.”
  6. (30s) Prior-year window. “Today is April 24. The IRS prior-year window closed nine days ago. Watch ContributionYearService enforce the calendar.” [Run section 4.4 cURL.]
  7. (30s) Withdrawal with reason. “The new POST /ach/withdrawal/with-reason endpoint supports RMD and excess-return paths.” [Run section 4.5 cURL.]
  8. (30s) Close. “Three rule services live. Three more wired with their logic in place, waiting for a payload variant. Everything is in the repo, the static site is the durable artifact, and the code compiles clean. Wave 3 is recurring + tax withholding + real 1099-R / 5498 emission.”
7. Stubbed vs. complete — the honest version.

8. If you want to play with it yourself

All four services run locally on Kaushik’s laptop.

9. Related pages + docs.