What we built, how it plugs into the NLZ pipeline, and what the demo will show — in plain English.
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.
BIN)TA)BBK, BFL, BMM)POST /ach/contribution, POST /ach/withdrawal/with-reason, DELETE /ach/queued/{txId}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.
| Step | What it does | Wave 2 change |
|---|---|---|
| 1. Parallel fetch | Calls account details, restrictions, transfers, and BETA instructions simultaneously. | Now reads from 60-account mock; parallel-complete logs confirm every hop. |
| 2. Validate AV details | Confirms account id matches the lpl account number returned by the BETA instruction. | None. |
| 3. DB insert | Writes the transaction skeleton into the movemoney Postgres. | None. |
| 4. Notify (fire and forget) | Queues a notification; short-circuited in POC. | None. |
| 5. RTT submitted | Marks the real-time-tracking record as submitted; short-circuited in POC. | None. |
| 6. EligibilityService | Runs 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. ComplianceService | Existing inline compliance checks. | None. |
| 9. Approved + audit | Writes approval audit record. | None. |
| 10. Completed + RTT + Kafka | Finalises the RTT record and emits the Kafka event; short-circuited in POC. | None. |
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.
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.
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
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
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
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.
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.
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.
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.
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.
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).
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.
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.
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.
| Method + Path | Purpose | Wave 2 service driving it |
|---|---|---|
POST /ach/contribution | Accepts 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-reason | Accepts 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).
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.
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.”
HsaEligibilityService fires, pipeline halts with a specific reason.”
ContributionYearService enforce the calendar.” [Run section 4.4 cURL.]
POST /ach/withdrawal/with-reason endpoint supports RMD and excess-return paths.” [Run section 4.5 cURL.]
All four services run locally on Kaushik’s laptop.
powershell -File ~/code/ach-poc/start-all.ps1. Stops with stop-all.ps1. Also auto-starts at login via the ach-poc-services Windows Task Scheduler job.http://localhost:5001/health — process-apihttp://localhost:5002/cam-movemoney-system-api/swagger — system-apihttp://localhost:5003/cam-mm-instruction-system-api/swagger — instruction-system-apihttp://localhost:5004/swagger — mock-api (plus /mock/accounts/10000024 for a sample)10000024 — RRH Roth IRA, holder age 65 (catch-up-eligible). Use for the over-limit and happy-path demos.10000047 — HMM HSA, HDHP=false. Use for the HSA rejection demo.10000001 — BBK brokerage. Use for non-retirement deposit/withdrawal demos.~/code/ach-poc/WAVE2-LIVE-RULE-EXECUTION.md, ~/code/ach-poc/WAVE2-IMPLEMENTATION-STATUS.md (full detail for the 54-use-case breakdown).