Overview
A multi-tenant SaaS for county tax collectors: parcels, bills, cash register, solid-waste billing, work orders and reporting. A .NET 10 API on PostgreSQL behind a React 19 admin portal, shipped to AWS ECS through four environments.
- 239
- commits by me
- 135
- pull requests merged
- 74
- Jira tickets delivered
- 22
- months on the platform
Catalis Tax is the second-generation, multi-state version of a county tax system that previously shipped one codebase per state (the "Multi-County Tax Solution v1.0", known internally as Refactored LandNav / PrimeTSR). Version 2.0 rebuilt it as one cloud tenant per customer on a shared .NET and React codebase, with state-specific behaviour isolated behind configuration and per-state modules.
Users are tax collectors and their staff. They look up a parcel, see every bill and payment against it, take money at a cash register, print receipts, run year-end rollovers, import appraiser data and run statutory reports. Mistakes are measured in dollars and audit findings, so correctness is the product.
My role
I joined the team in December 2024 as a full-stack developer working through the outsourced engineering partner, and have shipped across both repositories every month since: the ASP.NET Core API and the React admin portal.
- Full-stack developer in both repositories, December 2024 to present.
- Own tickets end to end: database function, API, UI, pull request, QA follow-up.
- Customers shipped for: Palm Beach County Solid Waste Authority (FL) and Jackson County (MS).
- Team of about fourteen: product owner, BA and SME, lead dev manager, three to five developers, QA, PMO.
The platform in numbers
- 649
- HTTP endpoints
- in 95 controllers
- 450
- EF Core entities
- 168 schema scripts
- 127
- routes in the portal
- 70 feature folders
- 59
- Combit report templates
- 51 Florida, 13 Mississippi folders
- 44
- Quartz jobs per tenant
- 517
- xUnit tests
- 145 files, fully offline
- 8,172
- commits across both repos
- since October 2022
- 4
- environments, dev to prod
Impact
Six changes county staff and taxpayers feel every day. Each row shows who it helped, what the screen did before and after, the figure that proves it, and which layers of the stack the work went through.
4
critical defects closed
Receipts that never change after printing
Jackson County, MS · cashiers and taxpayers
- Before
- A receipt was re-derived from the live account, so a later payment silently changed one the taxpayer already held.
- After
- Each payment stores its invoice amount and the lines it paid. The receipt renders from that snapshot, the same way forever.
Receipts you can trustTCCT-3645, 3773, 3839, 3315, 3507, 3885- Database (yes)
- API (yes)
- Portal (yes)
12
tickets, data model to grid
Inspections recorded inside the tax system
Palm Beach County SWA · field inspectors and billing
- Before
- Inspector visits to verify units, building use and service area lived outside the tax system.
- After
- Tasks, notes, status history, attachments and per-building findings sit on the parcel, and corrections reach billing with an audit trail.
Work Order moduleTCCT-1239, 1253, 1254, 1638, 3097, 3100- Database (yes)
- API (yes)
- Portal (yes)
2
reports off spreadsheets
Statutory reports in one click
Palm Beach County SWA · finance
- Before
- The account balance summary and the property address listing were built by hand in spreadsheets.
- After
- Both run as PostgreSQL functions behind the Combit report server, and the understated interest column was fixed before year-end close.
Reports and importsTCCT-3281, 3411, 3481, 3295- Database (yes)
- API (yes)
- Portal (no)
6
validators on every row
Failed imports became a worked queue
County staff loading appraiser, hauler and CO files
- Before
- One bad row in a fixed-width file stopped the whole import.
- After
- Failing rows are tagged with a reason, and staff correct, reject or reprocess them on screen while the rest of the file completes.
Import Exception screenTCCT-1550, 1478, 1683, 1802- Database (no)
- API (yes)
- Portal (yes)
13
WCAG tickets closed
Accessible to every clerk and taxpayer
Both tenants · audited against WCAG 2.1 AA
- Before
- WAVE reported errors on the Home page and menus, the Cart, the Parcel Record and its drawers, and the user screens.
- After
- Every WAVE finding raised against those screens is closed for both tenants, with labels, names, landmarks and contrast fixed.
AccessibilityTCCT-2515, 2637, 2859, 2865, 2992, 2592, 2595- Database (no)
- API (no)
- Portal (yes)
$100
minimum-payment threshold
Money rules the cart and the API agree on
Jackson County, MS · cash register
- Before
- Under $100 the minimum could exceed the balance, and the cart flagged a legitimate tender as an overpayment.
- After
- The API is the source of truth for the minimum, per item subtype, and the cart mirrors it.
Money rulesTCCT-2718, 2793, 2871, 3553- Database (no)
- API (yes)
- Portal (yes)
Five of the six changes went through at least two layers of the stack, and two went through all three: the SQL, the service and controller over it, and the screen that calls them, each ticket owned end to end.
The challenge
Property tax in the United States is administered by counties, under state law. Florida bills solid-waste assessments through the tax roll and sells tax certificates. Mississippi levies privilege licences by month. Virginia taxes vehicles. South Carolina runs tax sales. Each state has its own penalty and interest schedule, discount periods, installment plans, exemptions and statutory report formats.
The previous product line solved this by forking: one deployment per customer, drifting apart over years. Catalis Tax 2.0 set out to replace that with a single multi-tenant SaaS where a county is a tenant with its own PostgreSQL database and configuration, and the application code is shared. That shape creates the engineering problems this case study is about.
- Correctness under variation
- A receipt must show the right invoice amount whether the bill was paid in full, in installments, partially, reversed or refunded, and the rules differ by item type and by state.
- Reference data at scale
- Dozens of lookup tables (DOR codes, hauler codes, boundary tables, rates, rules) that staff maintain themselves and that roll over every tax year.
- Files in and out
- Appraiser rolls, hauler and CO files, bill-print exports: fixed-width formats from legacy mainframe systems, with an exception workflow when rows fail validation.
- Public-sector obligations
- ADA and WCAG accessibility, auditability, role and permission control per tenant.
Customers I shipped for
- Palm Beach County SWA, FL
- Solid Waste Authority billing through the tax roll: boundary tables, hauler codes, installments, work orders and inspections, CO and NAV file imports. Three-year agreement, kickoff August 2024.
- Jackson County, MS
- Full tax collection: cash register, receipts, privilege licences, bill-print export, supplemental bills, multi-owner parcels.
Vocabulary used on this page
- Parcel / PCN
- A taxable property and its control number.
- Roll
- The year's list of parcels, owners and values from the appraiser.
- Bill, invoice, installment
- A bill becomes an invoice with line values; it may be split into installments.
- Millage
- Tax rate per thousand dollars of value, per taxing district.
- SWA
- Solid Waste Authority: collection, disposal and recycling units billed on the roll.
- Bump file
- The SWA's nightly change file that updates billable items.
- Unallocated cash
- Money received but not yet applied to a bill, including overpayments.
- TRIM
- Florida's Truth in Millage notice; the platform exports its data.
System architecture
A browser SPA, a stateless API, a database per tenant, and workers around the edge. The accent line is the path a request takes from a click in the portal to a row in PostgreSQL.
Client
React 19 admin portal
Vite, MUI v5, Zustand, router v7
oidc-client-ts: code flow, silent renew
127 routes, 70 feature folders
Public pages
Rates and rules tables
public cart and search, no sign-in
Identity server (OIDC)
Code flow, JWT bearer tokens
refresh grant at /connect/token
roles per tenant, linked tenants
Edge and runtime, AWS us-east-1
nginx container, ECS
Static build, SPA fallback, HSTS
envsubst injects config at start
/healthr for the target group
ASP.NET Core 10 API, ECS
95 controllers, 649 endpoints
Controller, Service, UoW, Repo
FluentValidation, AutoMapper, NLog
Background jobs, EC2
Quartz.NET, 44 jobs per tenant
imports, exports, bills, reports
SFTP via SSH.NET
Data and integrations
PostgreSQL, one DB per tenant
EF Core 10 and Npgsql, 450 entities
row-level security via SET LOCAL
fxcat_report_* SQL functions
AWS S3
Documents and attachments
import and report files
Combit report server
59 templates (FL, MS)
backed by SQL functions, to S3
- Stateless API
- Every request carries a JWT. The tenant is resolved from the token and the request is bound to that tenant's connection string, so the same container image serves every county.
- Envelope contract
- All endpoints return { Succeeded, Data, Message }. The React API client unwraps it, returning Data or throwing Message, so screens never branch on HTTP shape.
- Same image, four stages
- Dev builds the image; QA, Stage and Prod promote the exact digest. Configuration is injected at container start, never baked in.
The request pipeline
What happens between a click in the portal and a row in PostgreSQL. The tenant is never a parameter: it comes from the token, binds the connection, and reaches the database as a session setting that row-level security policies can read.
JWT bearer
IdentityServer token
SecurityCheck
user from token, verb to permission
Model validate
DataAnnotations to ValidationException
Controller
MiddlewareController base
Service
new XService(TenantId, UsNumb)
UnitOfWork
ConnectToTenant, lazy repositories
Npgsql
SET LOCAL tenant and user
- The security filter reads the user from the token and places TenantId, UsNumb, user name and IP into the request context.
- The unit of work looks up the tenant in the global tenant master and builds that tenant's connection string from a template plus the tenant's own database credentials.
- A connection interceptor runs SET LOCAL request.tennant_id and request.user_id on open, which PostgreSQL row-level security policies can read.
- Outbound, InvokeHandlerMiddleware catches every exception, maps it to 400, 404 or 500 inside Response<T>, and logs the request and response pair to the API call log table.
Backend
Twenty projects, one request pipeline, and a database per county. The solution is cattax.sln on .NET 10: EF Core 10 with Npgsql, AutoMapper, FluentValidation, NLog, Quartz, Swashbuckle, the AWS S3 SDK, SSH.NET, FileHelpers, EPPlus, OpenXML, DinkToPdf and EFCore.BulkExtensions.
- CTX.Services398 files44k
- CTX.Entities450 entities, 168 SQL scripts17k
- CTX.ImportExportFilesparsers, exporters, SFTP16k
- Cattax (Web API host)95 controllers13k
- CTX.Tests145 files, 517 tests12k
- CTX.Calculationspenalty, interest, discount, bills8.5k
- CTX.CartManagerCart.cs, Payments.cs4.8k
- PciToCatalisMigrationlegacy ETL4.6k
- CTX.RepositoriesGenericRepository<T>4.1k
- BackgroundProcessorQuartz, 44 jobs3.1k
- CTX.UnitOfWorkFactorylazy repositories2.9k
- CTX.ReportCombit REST client1.1k
- CTX.Configuration761
- CTX.AWSS3377
- CTX.IdentityServer317
- CTX.Encryption268
- GET284
- POST149
- PUT124
- DELETE91
- PATCH1
- Schema as scripts
- The DbContext is scaffolded, with no EF migrations. Every schema change ships as an ordered SQL script in the Entities project, and release folders hold the migration sequence for each tenant.
- Services own their scope
- A controller action creates its service with the caller's tenant and user, and disposes it at the end. Each service opens its own unit of work, so there is no shared DbContext between requests.
- Global versus tenant
- A separate non-tenant unit of work serves global tables: tenant master, process jobs, permissions and API call logs. Everything else is read through the tenant's own connection.
- How the states differ
- Almost entirely through data: per-tenant settings, process-job rows, item types and rate tables. The exceptions are deliberate modules: 32 FL SWA lookup controllers and the AG, CO, NAV and BUMP imports for Palm Beach; Patriot imports, privilege licences and a mobile-home penalty branch for Jackson County; three Virginia import jobs.
Background processor
A Quartz.NET console service with 44 job classes, deployed per environment and state on Windows EC2. On start it reads every enabled process-job row for every tenant, resolves the class by reflection and schedules its cron expression. Work arrives as process-queue rows written by the API; a base job claims the next row, heartbeats every ten minutes, marks it complete or complete-with-errors and emails on failure. Stuck rows reset on restart. Jobs cover appraiser, hauler, CO, bump and Patriot imports; bump, TRIM, bill-print, escrow and tax-sale exports; bill calculations; report rendering; bill PDFs; the SWA new-year roll; and bump rollback.
Calculations
Tiered penalty and interest rates with a cumulative cap and minimum amount; tiered discounts by effective date; bill-run to invoice; prorated mobile homes; privilege licences by month with sliding or flat rates; SWA non-government and government bills; and minimum payment, where any item under $100 is due in full.
Frontend
A React 19 portal with 127 routes, one auth singleton and six stores. Seventy feature folders in total; beyond the largest, most are reference-data maintenance screens (DOR codes, hauler codes, boundary codes, SWA class codes) sharing one table-and-drawer CRUD pattern.
- ParcelRecord62 files31k
- ParcelRecordVA66-file Virginia fork27k
- Account7.9k
- PrintFiles (receipts)11 templates7.5k
- AddItem5.0k
- CustomUI (table, tabs, top bar)4.2k
- Home (search)4.1k
- Cartthe cash register3.8k
- Users3.3k
- MillageRate3.2k
- NAVFileData2.5k
- MotorVehicle2.2k
Provider tree, App.jsx
- ThemeProviderMUI, light or dark per user
- BrowserRouter
- I18nProvideren / es scaffold
- PermissionsProvider/userpermissions
- SettingsProvider/GetAllSettings
- CartProviderpayment cart
- RouterMain127 routes
- AuthRoute, Layout, Outlet
Stores, Zustand
- userStore
- auth user, persisted to session storage
- GlobalStore
- posting date, unallocated cash, payment types, rows per page per table
- cartStore, paymentStore
- tender, partial payment, payee; cleared on logout
- searchStore, transactionSearchStore
- criteria, paging, sort
- Auth and token refresh
- Login runs the authorization-code flow with oidc-client-ts. Between twenty minutes after sign-in and two minutes before expiry, each call exchanges the refresh token at /connect/token and writes the new token back to the store. An expired token logs the user out.
- Tenancy in the UI
- Tenancy is per user. The top bar offers "Change my tenant" from the user's linked tenants; a server call switches and the app reloads. Tenant and client-type settings gate menu items, item types and the appraiser link.
- Accessibility baseline
- 626 aria attributes across 135 files, keyboard-enabled rows in search and the top bar, focus styles in the MUI overrides. The WAVE tickets I worked added most of the labels and names.
- Known debt
- Plain JSX without type checking, a near-copy ParcelRecordVA fork, i18n scaffolding with seven keys, no unit tests, no skip links. Listed because it shaped my decisions.
The screens that matter
Parcel Record is the product. Sixty-two files and 30,581 lines sit behind that one screen: invoice actions (void with reason, undelete with a required note), installment and split-bill panels, supplemental bill drawers for real estate, personal property and SWA, payment history, S3-backed document maintenance and the change log.
The cart is where the money moves. Cash over the total due opens a Change Due modal. Check over the total due offers a refund check or "keep in unallocated cash". Paying below the minimum needs a supervisor override. Receipts switch template by item type: real estate, personal property, mobile home, solid waste, government, privilege licence, aircraft, transient business.
Add bills
POST /Cart
Edit lines
amount, dates
Tenders
cash, check, card
Complete
POST /CompletePayment
Receipt
@react-pdf/renderer
Receipts render in the browser with @react-pdf/renderer: eleven templates chosen by item type, with the tenant's watermark and seal from the global store. Work orders export to PDF with html2pdf, the account page uses jsPDF with autotable, and bills are rendered server-side by DinkToPdf.
Screen by screen
Eighteen screens of the portal, redrawn in the product's own theme. These are faithful re-creations of the real layouts, not screenshots of customer data; every value shown is illustrative. Pick a screen, then hover, focus or tap a callout to light the part of the screen it explains.
Screen 3 of 18
Parcel record: invoices

The hub for one account: header, tabs, invoices, summary and payment history.
The postmark date chip changes interest and discount; the tab set follows the item type; each invoice expands to its lines and line values; the row menu holds supplemental bills, chargebacks, bankruptcy, printing, removal and undelete; the payment history panel opens receipts and starts voids and reversals.
Contributions
Every month since December 2024, in both repositories. Front end in the accent, back end in ink.
| Month | Front end | Back end |
|---|---|---|
| Dec 24 | 3 | 12 |
| Jan 25 | 0 | 0 |
| Feb 25 | 3 | 2 |
| Mar 25 | 4 | 6 |
| Apr 25 | 6 | 5 |
| May 25 | 4 | 2 |
| Jun 25 | 1 | 5 |
| Jul 25 | 6 | 9 |
| Aug 25 | 2 | 2 |
| Sep 25 | 12 | 4 |
| Oct 25 | 4 | 0 |
| Nov 25 | 3 | 0 |
| Dec 25 | 5 | 1 |
| Jan 26 | 9 | 2 |
| Feb 26 | 9 | 2 |
| Mar 26 | 10 | 6 |
| Apr 26 | 12 | 2 |
| May 26 | 5 | 3 |
| Jun 26 | 8 | 4 |
| Jul 26 | 6 | 11 |
| Aug 26 | 12 | 8 |
| Sep 26 | 9 | 6 |
| Oct 26 | 7 | 7 |
Where the tickets went
- Bugs, defects and hotfixes56
- Work orders, service requests40
- Payments, cart, installments38
- Accessibility (WCAG / WAVE)34
- Receipts and print files30
- Imports, processes, exceptions16
- Reports and SQL functions12
| Repository | PRs merged | Lines added | Lines removed |
|---|---|---|---|
| FrontEnd | 81 | 14,153 | 6,008 |
| BackendAPI | 54 | 35,266 | 29,156 |
| Total | 135 | 49,419 | 35,164 |
Branch per ticket into dev, reviewed PRs, promoted through QA and Stage.
Files I touched most
Front end, commits per file
- PrintFiles/Invoice/index.jsxreceipt template switch11
- CustomUI/EnhancedDataTable.jsxshared data table11
- Cart/index.jsxcash register10
- PrintFiles/Invoice/REReceipt.jsxreal-estate receipt9
- ParcelRecord/BillsTabV2/index.jsxitems and assessments8
- ParcelRecord/WorkOrdersTab/BuildingDetails.jsxbuilding details grid7
Back end, commits per file
- CTX.Services/TaskMasterService.cswork orders9
- CTX.Services/InvoiceService.csinvoices and receipts9
- Controllers/TaskMasterController.cs7
- CTX.Services/ReportsService.csreport wiring6
- CTX.Cart/Cart.csminimum payment, tender rules5
- Combit/FL/…/fn_ihsumm.sqlIHSUMM report function4
Timeline
From lookup-table CRUD to the money paths.
Dec 2024
Onboarding on FL SWA lookup tables
SWA owner codes, models, DTOs, repositories registered in the unit of work. First merged backend PR on 31 December.
Feb to Mar 2025
NAV appraiser import, rollover rates, Changelog tab
NAV appraiser file import (TCCT-394), millage rate rollover (TCCT-407), installment group fields, a Changelog tab on the Parcel Record, a payment-calculation fix for Jackson MS.
Apr to Jun 2025
Boundary rollover, building cards, supplemental bills
Roll the SWA boundary table into the new tax year (TCCT-899), redesigned building-card records, supplemental bill drawers (TCCT-836), initial installment creation (TCCT-1006), unallocated cash (TCCT-434), DR-409A generation.
Jul 2025
Work order data model
Database and backend for TaskMaster, TaskNotes and TaskStatus (TCCT-1239, 1253, 1254); work order fields, notes sorting, tab sorting.
Aug to Sep 2025
Import Exception worker screen
Backend (TCCT-1550) and frontend (TCCT-1478, 1683) for reviewing, correcting, rejecting and reprocessing rows that failed import. Also the MC-2 account header and MC-4 sub-navigation.
Oct to Dec 2025
Accessibility and user admin
WCAG fixes from WAVE reports on checkboxes, empty buttons, Parcel Record and Cart; user tenant management; receipt after void or reversal; multiple owners on parcels.
Jan to Apr 2026
Service requests, WCAG, minimum payment
Service Request tab (WO-01) back end and front end; Service Request Area (M-1); WCAG passes on Home and menus for two tenants; minimum payment under $100 for real estate, personal property and mobile home; database name shown in Swagger.
May to Jul 2026
Building details and statutory reports
Building Details grid (MC-4) with add-row "Added by Inspector", edit, save and discard with status auto-population, soft delete with reason and undelete. In-House Account Balance Summary (IHSUMM) and Property Address Listing reports as SQL functions.
Aug to Oct 2026
Receipt integrity for Jackson MS
Four critical receipt defects: dynamic adjustment, wrong invoice amount after paying a partial balance, edit-payor not saving, duplicated ZIP. Interest missing from IHSUMM. Cart minimum-payment flag. Hotfix: invoice lines paid up to the payment on receipts.
Spotlights
Work Order module
Twelve tickets, from three new tables to an inspector's editable grid. The Palm Beach SWA sends inspectors to properties to verify what is being billed: how many units, what building use, which service area. Those visits had lived outside the tax system. The Work Order module puts the task, its notes, its status history, its attachments and the per-building findings on the parcel itself.
| Building | Use code | Units | Status | Source |
|---|---|---|---|---|
| BLDG 1 | 0100 Single family | 1 | Verified | Roll |
| BLDG 2 | 0400 Multi-family | 4 to 6 | Modified | Roll |
| BLDG 3 | 2300 Commercial | 1 | Added by Inspector | Inspector |
| BLDG 4 | Shed, not billable | 0 | Deleted, duplicate | Roll |
- Inspection date
- 2026-06-18
- Service area
- Area 3 · Hauler 12
- Notes
- Unit count corrected on site
Interface fragment drawn from the real screen's structure. No customer data.
What I built
- Tables and services for TaskMaster, TaskNotes and TaskStatus: paged listing with include-disabled and hide-completed filters, notes and files CRUD, soft delete.
- New work-order fields, attachment validation, empty-state fixes (no more 12/31/1969 dates).
- Service Request tab (WO-01) and Service Request Area (M-1): back end and front end.
- Building Details grid (WO-02.4): add row as "Added by Inspector", inline edit, save or discard, status auto-populates on modified rows.
- Soft delete with a required reason (demolition, lot change, reclassification, duplicate, other with notes), undelete, and a show-deleted toggle; deleted rows keep who, when and why.
- Permission gating through the portal's permission context.
Decisions worth noting
- Status history is its own table rather than a column, so an auditor can see who changed what and when.
- Delete is a soft delete with a reason because inspectors' findings are evidence; nothing billed is ever lost.
- Row history is kept per building, with revert gated by its own permission and the current tax year.
Receipts you can trust
A receipt is a legal document. It must not change after it is printed. Jackson County went live on the cash register and QA found a family of defects that all came from one design flaw: receipts were rendered from the current state of the account, not from the state at the moment of payment. Pay the remaining balance of a partially paid invoice and the old receipt silently changed. Edit the payor and the receipt did not update. Pay in two steps and the invoice amount shown was wrong.
Try it. Post the second payment under each rendering and watch what receipt 1 says.
Account activity
- Invoice 2026 RE 00412-A1,000.00
- Payment 1 · receipt printed600.00
- Payment 2 not yet posted—
Receipt 1 as printed at the register.
Receipt fragment drawn for this page. Values are illustrative.
- Snapshot at payment
- The receipt now carries the invoice lines paid up to that payment and an InvoiceAmount captured when the tender posts, returned by the API and stored with the payment.
- Single-invoice rule
- The receipt uses the captured invoice amount as Total Due only for single-invoice receipts; multi-invoice receipts sum lines, and Total Due is clamped at zero.
- Partial payloads preserve values
- The update-details endpoint merges rather than overwrites, so editing a payor cannot blank other fields.
- Template fixes
- Paid-date timestamp, mailing address, ZIP field duplication, Owner Name 2 hidden when blank, across the real-estate, personal-property, mobile-home and general receipt templates.
- TCCT-3645Receipts should not adjust dynamically
- TCCT-3773Incorrect invoice amount after paying remaining balance
- TCCT-3839Invoice lines paid up to the payment on receipts (hotfix)
- TCCT-3315Edit payor does not save; receipt not updated
- TCCT-3507Paid date missing timestamp
- TCCT-3885ZIP field duplicated on refund receipt
Reports and imports
The two edges of the system where the mainframe era still lives.
In-House Account Balance Summary (IHSUMM). A finance report the SWA runs to reconcile receivables. Delivered as a PostgreSQL function, fn_ihsumm, registered in the report menu tables and rendered by the Combit report server. First version shipped June 2026 (TCCT-3281), then a defect pass (TCCT-3411). The August fix: interest from the paid section was not captured, so the INTEREST column read 0.00 and the total balance was understated (TCCT-3481). Total due now includes payment history.
Property Address Listing (report 129). A roll listing by property address. SQL function plus a report-number guard, CSV only, and the "All" filter requirement removed after customer feedback (TCCT-3295). Also CO report updates (TCCT-1764) and the DR-409A certificate.
-- fn_ihsumm.sql (excerpt) · Combit/FL/IHSUMM
CREATE OR REPLACE FUNCTION public.fn_ihsumm(p_tax_year integer)
RETURNS TABLE (pcn varchar, owner_name varchar,
bill_amount numeric, interest numeric,
payments numeric, balance numeric)
LANGUAGE plpgsql AS $BODY$
BEGIN
RETURN QUERY
-- IH bill-installment lines for the year
WITH ih_line AS (
SELECT ald."ArLKey", inv."ArVKey", inv."AcKey"
FROM "ArInvoiceMaster" inv
JOIN "ArLineData" ald ON ald."ArVKey" = inv."ArVKey"
JOIN "BillInstallmentData" bid ON bid."BidKey" = ald."BidKey"
WHERE inv."ArVBillYear" = p_tax_year::SMALLINT
AND bid."BsKey" = 'IH'
),
-- line values, canonical type, IH payments …
END $BODY$;
Import Exception worker screen. Appraiser (NAV), hauler and CO files arrive as fixed-width or delimited text. Rows that fail validation used to stop the whole file. The exception screen, specified by the BA in Confluence and built by me on both sides, turns that into a workflow: six pluggable validation workers (account number, amount range, missing column, data type, required field, duplicate) tag each failing row with a reason.
File uploaded
to S3, queued as a process row
Parsed row by row
Quartz job, six validators
Completed with exceptions
each failing row tagged with a reason
Process Exceptions screen
view, edit record, reject, save with confirmation
Reprocess
resolved rows become records
Completed
rejected rows read-only afterwards
Also on the file side: the NAV appraiser import (TCCT-394) with its header and detail tables and the process-queue model; boundary table rollover into the new year, parts 1 and 2 (TCCT-899); report queue time display and date format for the SWA (TCCT-3515); report 129 restricted to CSV and All Residential Collection Units (CIS-089) handed to Combit.
Accessibility
Government customers audit against ADA and WCAG 2.1 AA with the WAVE browser extension, and every error on a page is a finding. Over ten tickets I cleared the reports on the Home page and menus for both tenants, the Cart, the Parcel Record and its drawers, global search, and the user admin screens.
- 626
- aria attributes in the portal today
- 13
- WCAG tickets I closed
- 2
- tenants audited page by page
- Home and menus (2 tenants)5
- Parcel Record and drawers4
- Cart2
- Global search1
- Users and tenants1
- Form labels and aria-label on every input in the Edit Item Attributes and Update Item Details drawers (16 missing labels in one drawer alone).
- Empty buttons given accessible names; icon-only controls described.
- Checkbox and label association in tables; contrast fixed on status text.
- Landmarks and heading order on Home so screen readers can navigate.
Money rules
Mississippi lets a taxpayer pay a minimum amount on real estate, personal property and mobile home bills. The rule misbehaved when the amount due was under $100: the "minimum" exceeded the balance, and the cart flagged a legitimate tender as an overpayment.
- Backend: effective minimum becomes the balance when the balance is under $100, per item subtype (TCCT-2871).
- Frontend: cart mirrors the rule and the Minimum Payment button no longer flags overpayment when tender exceeds the minimum but not the total due (TCCT-2718, 2793, 3553).
- Follow-on in progress: underpayment over the threshold (TCCT-3968).
Other correctness work
- Voiding a reversal re-applies the original payment (TCCT-670).
- Discount removal and interest and penalty application on account and invoice (TCCT-1053).
- Interest and discount displayed for every item type on the Parcel Record (TCCT-2342).
- Implicit integer division corrected in a calculation path.
- Unallocated cash: ability to add to it from the register (TCCT-434).
- Initial installment creation for the SWA (TCCT-1006).
Delivery pipeline
Build once, promote the same image through four environments. Azure DevOps pipelines, AWS ECS, Terraform.
Branch per ticket
TCCT-nnnn-slug off dev
Pull request
AI review task plus human review
Dev build gate
xUnit suite must pass
Dev deploy
image to ECR to ECS, circuit-breaker rollback
QA promote
same digest, retagged
Stage copy
to the prod AWS account
Prod promote
behind an approval
# Dockerfile, build stage
RUN jq 'to_entries | map({(.key): ("$"+.key)}) | from_entries' \
src/config.json > src/config.tmp.json
RUN npm run build
# runtime stage: nginx:alpine, non-root, 8080
ENTRYPOINT ["start-nginx.sh"] # envsubst at start
- Runtime configuration without rebuilds
- The React app imports a JSON config module at build time. The Dockerfile rewrites every value to a $KEY placeholder before vite build; at container start, start-nginx.sh runs envsubst over the bundled JS. The same image therefore serves dev, QA, stage and prod with different API hosts and OIDC clients.
- Hardening in the edge container
- HSTS with preload, nosniff, frame-ancestors same-origin, strict referrer policy. nginx runs as the nginx user with server tokens off and gzip on text assets. try_files $uri /index.html keeps deep links alive on refresh; /healthr answers the ECS target group.
- Backend quality gate
- xUnit v3, NSubstitute and EF Core InMemory: 517 tests in 145 files, fully offline. Tests exercise controller to service to unit of work to repository against an in-memory database, plus the fixed-width parsers. The dev pipeline runs the suite before the image is built; SonarQube runs it with coverage weekly on main. Swagger shows the connected database name so nobody tests against the wrong tenant (my TCCT-2642).
- What Terraform provisions for the API
- ECS task definition and service, security group, ALB target group and listener rule, CloudWatch log group and the IAM task role. Database credentials come from SSM parameters; state is remote; dev and prod have their own variable files. The API image is aspnet 10 on port 5211 with the Chicago time zone.
- Background processor deploys separately
- One Windows EC2 service per environment and state (FL, MS). A PowerShell script stops the process, unzips the build and copies everything except the environment's settings file. Restart is a deliberate manual step so an overnight import is never interrupted mid-file.
Reflection
What changed in how I work, after twenty-two months on the platform.
What I would do differently
- Snapshot receipts from day one. Every receipt defect traced back to rendering from live state; the fix was obvious in hindsight and expensive in sequence.
- Push for a Vitest suite in the front end. The backend has a real gate; the portal's only automated check is an AI review.
- Name fields once. Casing mismatches between DTOs and JSX (UsNumb, ArYTKey) cost real hours; a shared contract or TypeScript would have paid for itself.
What I learned
- Own the whole slice.
- The tickets I am proudest of touch a SQL function, a service, a controller, a store and a drawer. Being the one person across that path is why they shipped in one sprint.
- Money logic belongs in one place.
- Minimum payment lived in the cart and in the API; keeping them in step was the bug. The backend is now the source of truth and the UI mirrors it.
- Accessibility is a checklist, not a vibe.
- WAVE made it measurable, and measurable work gets finished.
- Read the Confluence page first.
- The exception-screen spec answered every edge case before I wrote a line.
- 239
- commits by me, both repositories
- 135
- pull requests merged
- 74
- Jira tickets delivered
Feature catalogue
Part one told the story. This part is the reference: what each screen shows, which endpoints it calls, what rules the backend enforces, and where I worked on it. Facts were read from the code on 14 October 2026.
Search and accounts: /home, /SearchComponent, /allResults, /parcelRecord, /parcelRecordVA, /paymentHistory, /addItem, /newItem, /mv, /districtDetails.
Cash register and work: /cart, /workorders, /ServiceRequestDetails. Processes and files: /processes, /ProcessDetails, /MaintainProcess, /NAVFileData, /AgFileData, /COFileData, /BUMPImport.
Rates and billing setup, around thirty SWA reference-data screens, attributes and configuration, and administration (/users, /user, /reporting).
The side navigation is not hard-coded. It comes from the API per role, so a Mississippi tenant never sees the Florida SWA lookup screens, and a cashier does not see administration. Public pages render without sign-in.
Where I worked. Global search accessibility (TCCT-2992), home-page WCAG passes for both tenants, transaction search filter fixes, and the link from a parcel to the Property Appraiser site by PCN (TCCT-1434).
Invoice search is the default tab: it finds bills to pay and adds them to the cart, with status filters from Active and Unpaid through Bankruptcy, Tax Sale and Tax Deed Transfer, and a warning when older unpaid invoices exist.
Transaction search filters by date presets, cashiers, payor, tender reference and amount range; rows expand to tenders and lines with receipt numbers, and support void and reverse.
Global search in the top bar hits name, description, address, parcel, account and legacy account through one endpoint, shows ten results inline and remembers history per user.
Where I worked. Global search accessibility errors (TCCT-2992), WCAG passes on Home for both tenants (TCCT-2515, 2637, 2859, 2865).
Header: account type and key, owner and attention name, mailing address with an edit pencil, unallocated cash, inactive banner and bankruptcy chip; status chips sorted by severity; a postmark chip that turns red when it is not today.
Tabs follow the item type: Invoices, Items and Assessments, Property Details or Details, Notes, History, Document Maintenance, Change Logs and Service Requests, so one component serves every state.
Invoices tab: cart split button (add, pay in full, pay minimum), expandable lines and line values, and a row menu for chargebacks, supplemental bills, bankruptcy, print invoice, undelete with a note and licence regeneration. Cart gates run in order: read-only users, take-note statuses, prior-year tax-sale and collections invoices, newer-before-older warnings.
Payment history with void and reverse through one modal, a payment details drawer, S3-backed document maintenance, and a change log: services write field-level before and after values with user and IP, so the tab is a per-account audit trail without a separate audit product.
Where I worked. Changelog tab (TCCT-688), show all owner names (TCCT-841), interest and discount for all item types (TCCT-2342), WCAG labels in the item drawers (TCCT-2592, 2595, 2094), redesigned building cards (TCCT-994), supplemental bills (TCCT-836), the receipt snapshot series (TCCT-3645, 3773, 3839).
Status warning list, account header, sub-navigation account controls, transfer history and invoices stack without a tab strip. Collapsible Critical and Warning cards carry counts and refresh when a status changes.
Virginia on the server: three import jobs, storm water (a real-estate fee in two installments), solid waste and nuisance abatement. Each runs the general importer first and creates invoices only when there are zero exceptions, within a fiscal-year window. Vehicle taxation is specified in Confluence and prototyped in the motor-vehicle screen.
Where I worked. Account header and tools base page (TCCT-1776), sub-navigation account controls (TCCT-1778), restore checkout cart in the VA wrapper (TCCT-1618, 1927).
Posting and postmark dates each behind a confirm, a running total, Minimum Payment, Clear Cart and Remove Selected. Lines expand to sub-lines, each with its own minimum. Tenders: cash, check, credit or debit, charitable credit, write off and internal transfer.
Rules: minimum payment only when no RE, PP or MH item is under $100; a line below its minimum asks for confirmation; a partial payment locks both dates; posting-date changes and write-offs need supervisor credentials by permission; charitable credit is first tender only, once, at most half of tax and fees.
Server side the cart manager is a 2,860-line class: add invoice, add and remove tender, change line amount and dates, complete payment with overpayment or change to unallocated cash, void, reverse (a whole cart, one invoice or specific lines, inserting a reversal cart and tender linked as corrections), refund check and transfer. Unallocated cash is a signed ledger: refunds post negative entries and cannot exceed the balance.
Where I worked. Minimum-payment rule on both sides (TCCT-2718, 2793, 2871, 3553, 3968), unallocated cash from the register (TCCT-434), receipt after void or reversal (TCCT-1815), VA wrapper checkout (TCCT-1927).
The invoice router loads the invoice or payment and the grouped account attributes, then picks a template by tax type: real estate, personal property, mobile home, solid waste (with government and certificate-of-correction variants), privilege licence, transient business, aircraft and mobile-home registrations, and a general receipt.
Shared anatomy: tenant header, account and receipt numbers, a tax-district table, assessed and exemption values, county, city and school breakdown, a watermark logo and VOIDED, REVERSED or DELETED overlays.
Where I worked. Invoice router and the RE, PP, MH and General templates (TCCT-2695, 3315, 3507, 3773, 3839, 3885), print invoice and view receipt modal fix (TCCT-2387), DR-409A certificate of correction.
Tabs for all reports, favourites, a queue under thirty days and completed runs with status chips. Parameters load per report with cascading children; validate first, stream small reports back as a file, queue the rest and poll every ten seconds.
A report is rows in the menu master, menu group and parameter-control tables (caption, type, range, default, required, child parameter, dropdown SQL) plus role and favourite links. The Combit template calls its own fxcat_report_* or fn_* function; the API calls the report server's export endpoint with the template id and writes the output to S3. A direct request falls back to the queue after twenty seconds. Templates live in the repo as .rsxfer files next to their SQL.
Florida SWA has 51 folders, from Account Status and the six bump reports to TRIM notice totals and unallocated cash history; Mississippi has 13, including settlement reports and privilege licence by month.
Where I worked. IHSUMM function and its defect passes (TCCT-3281, 3411, 3481), Property Address Listing report 129 (TCCT-3295), CO report updates (TCCT-1764), report queue time and date display (TCCT-3515), CIS-089 hand-off to Combit (TCCT-3833).
Users: sortable, searchable, show-disabled toggle; a drawer with a role checklist filtered to the user's tenant; enable and disable as a soft delete. Roles: permissions by item type and sub type with create, read, update, delete and supervisor-required flags; menu items and reports per role.
Every request passes a security filter that finds the user from the JWT and maps the HTTP verb to read, create, modify or delete on the permission named by the endpoint's tag; 60 distinct permission names exist in code. Supervisor-required permissions prompt for credentials and post them to the override endpoint.
Where I worked. User tenant management (TCCT-2013), roles hotfix and groups per tenant (TCCT-2112, 2116, 2107), disabled users visually distinguished (TCCT-3576, 3607), WCAG labels on the user drawers.
Each parcel carries buildings, and each building carries billing units with modifiers. Unit counts come from the appraiser roll, the nightly bump file, certificates of occupancy and inspector findings. Two engines: non-government reprices every unbilled installment from the current rates table (year, type, district, CSF code, PA range) and bills in batches; government tonnage is rate × square feet, or rate × units × modifier, billed as an HTML letter rendered to PDF from a SQL function. Finalize recalculates the installments, syncs bills, creates invoices and can apply unallocated cash.
Thirty-two FLSWA controllers and about thirty-three maintenance pages: boundary, hauler, DOR, CSF, compliance, exempt, PZB, property and municipality codes, service days, rates and rules tables, discount periods and note types. A background job creates next year's bill run and rolls each installment forward; the boundary, rates and rules tables and discount periods are copied from their own screens.
Closed gap: 1,808 SWA parcels (about $2.6M) had accounts but no billable item because the bump change branch never created one. The add branch now creates items for accounts with history but no active item.
Where I worked. SWA owner codes and the first lookup screens (TCCT-176), boundary rollover (TCCT-899), initial installment (TCCT-1006), items and assessments details (TCCT-739, 3595), tax-year dropdown blank page (TCCT-3679), SWA refresh issues (TCCT-1415).
Millage rate: a tax-year chip menu grouped future, current and previous; a year is editable only above the current billed year, and "Add year" rolls every element and district forward when the next year has no rates. Districts lock for closed years and a supervisor can unlock one until the session ends. A district group rate is the sum of its linked element rates; gross tax is taxable value × group rate ÷ rate base (1,000 for mills).
Mobile home rates by single-wide, multi-wide and Katrina cottage; mobile-home tax is width × length × 0.15 × rate × depreciation × (13 − month) ÷ 12. SWA discount periods apply as tiered discounts by effective date. The SWA rates table is keyed by year, type, district, CSF code and PA range, and a new year copies the latest; it and the rules table are public routes a property owner can read without signing in.
Where I worked. Rollover rates (TCCT-407), roll the SWA boundary table to the new year, parts 1 and 2 (TCCT-899), "unable to add additional mill rate" fix (TCCT-1955), rates table controller and service changes.
NAV is the non-ad-valorem roll from the Property Appraiser: 1,200-character fixed-width records, one per parcel, edited through a 1,644-line drawer. The table is staging only; separate passes rewrite legal descriptions and owner and mailing addresses on the parcels, paged by key (a 662k-row file once took over seven hours). AG carries agricultural classification and refuses a repeated file. CO certificates of occupancy arrive pipe-delimited; rows under 33 columns are rejected, and a missing PZB code, PA category or boundary becomes an exception in a CSV on S3.
None of the four screens uploads a file. Files go from the Processes screen into S3, become a process-queue row, and are picked up by the matching Quartz job. The bump job stages two fixed-width files (parcel master and buildings) and flags each parcel add, change or delete; seven Excel reports match the legacy R187 output. Because the import commits across about thirty saves with no transaction, a rollback snapshots twelve tables before and after, compares rows three ways, reverts only rows unchanged since the run, reports conflicts, and blocks a new bump while a rollback is pending.
Where I worked. NAV import file for appraiser data (TCCT-394) with its header and detail tables and the process-queue model, SWA refresh issues (TCCT-1415), bump reports hand-off (CIS-089).
TaskMaster, TaskNotes and TaskStatus with paged listing, include-disabled and hide-completed filters, notes and file CRUD and soft delete. The Service Requests tab reads the county's external QAlert system through the API and shows field-service and file-change approvals as status chips.
The Building Details grid: add a row as "Added by Inspector", inline edit with save or discard, status auto-population on modified rows, soft delete with a required reason, undelete and a show-deleted toggle.
Where I worked. Work order data model (TCCT-1239, 1253, 1254), Service Request tab (WO-01), Service Request Area (M-1), Building Details grid (WO-02.4).
Stack
- .NET 10
- ASP.NET Core
- C#
- EF Core
- PostgreSQL
- React 19
- MUI v5
- Zustand
- xUnit v3
- Docker
- AWS ECS
- Terraform
- Azure DevOps
- WCAG 2.1 AA



