# Underwriting engine and the ledger beneath it

> An East African insurer ran underwriting, finance and the people operations behind them on one platform. I built the underwriting engine, then owned Finance, HR and Payroll end to end, plus the middleware the rest of the product depends on.

A case study by Arif Hossen  
Source: https://arifhossen.dev/work/underwriting-engine-and-the-ledger-beneath-it

- **Role:** Software Engineer. The underwriting engine, then Finance, HR and Payroll, the policyholder API and its middleware, and security hardening
- **Team:** Three engineers and two interns, with requirements from the CEO, the CTO and finance and HR leadership
- **Duration:** A year
- **Stack:** Laravel, MySQL, Pest, OpenAPI
- **Period:** Jul 2025 to Jul 2026
- **Engagement:** Remote contract
- **3** Domains owned solo: Finance, HR, Payroll
- **65** Pull requests, the most on the team
- **2,030** HR and payroll test cases in five weeks
- **113** API paths in the published contract

## A small team, a large surface

Context

The platform is an East African insurer's core system: underwriting, finance and the people operations behind them. The engineering team was three developers and two interns, reporting to the CEO and CTO directly, with business stakeholders in the same conversations.

That size means domains get assigned whole, not split. Finance, HR, and Payroll were mine: requirements gathering, data model, backend, and the screens on top. When an accountant asked why a reconciliation did not balance, I was the person who answered.

## Rules as data, a premium that shows its working

Underwriting engine

Underwriting judgement usually starts life in people's heads and spreadsheets. The directors had approved how a risk should be assessed: which answers matter, which thresholds trigger a referral, which adjustments move the risk score. I designed the engine from those requirements and built it in one pull request of 81 files: a rule-evaluation service applies the rules, questionnaires collect the answers the rules need, and referrals and underwriting evaluations are part of the same engine, so a change of appetite is a data change, not a developer ticket.

I then added premium recalculation with new product fields and a premium breakdown, plus facultative and co-insurance actions on the quote screen. That screen sits inside the quotations module the lead engineer owned. They later built quotations, policies and endorsements on top of the engine, and I worked alongside them.

- *Rules* **Rule evaluation**: A service applies each rule to the risk and records the result, so every decision can be explained later.
- *Questions* **Questionnaires**: Each question exists because a rule needs the answer. The answers feed the evaluation.
- *Judgement* **Referrals**: A risk outside appetite goes to an underwriter with the reason attached, instead of being declined silently.
- *Price* **Premium recalculation**: New product fields recalculate the premium and show a breakdown an underwriter can check line by line.

## Everything that touches the ledger

The Finance module

Finance was the largest domain and the least forgiving: accountants notice when a number is wrong, and they are right to. I built it as one connected model rather than a set of screens, so that a payment, a bill, and a bank line all resolve to the same accounts.

| Area | What I built |
| --- | --- |
| Chart of accounts | Account hierarchy and posting rules every other finance feature writes through |
| Bank reconciliation | Statement matching against recorded transactions, with cheque books tracked per account |
| Budgets | Budget definition and tracking against actuals from the same ledger |
| Fixed assets | Asset register with lifecycle handling tied to accounting entries |
| Procure to pay | Purchase orders, vendor bills, and payments as one chain rather than three disconnected forms |

## Books that balance per fund and across years

Statements

An insurer's accounting answers to two masters: the underwriting side, which produces contributions, payouts and cessions every day, and the auditors, who want statements that balance and a history nobody can quietly rewrite. I built the IFRS-17-style chart of accounts coded to two funds, the journal entries and the general ledger that policy contributions, claim payments and treaty cessions post into, alongside the lead engineer who built the policy and claims side. The statements did not balance: parent accounts did not roll up their children, prior years did not carry forward, and retained earnings were not kept per fund. I rebuilt the balance sheet, trial balance and profit and loss in the calculation layer, without touching posted transactions, and proved each fix with tests.

- **Balanced entries**: The ledger refuses an entry whose debits and credits do not match.
- **Approval before posting**: Nothing reaches the ledger until a second person approves it.
- **Read-only history**: Posted entries cannot be edited. A mistake is corrected with a reversal.
- **Tamper evidence**: A hash is chained through each entry, so a changed record shows.
- **Period close**: Periods close on schedule and lock. Reopening the previous period needs approval.
- **Payroll gate**: Payroll approval refuses to proceed until every ledger account it needs is valid.

## Payroll is finance with feelings

HR and Payroll

Payroll has the same correctness bar as the ledger and a much shorter fuse: people notice their own salary immediately. I owned employee management, payroll records and payments, deductions, and advance salaries, with a biometric attendance integration feeding the hours.

The self-service portal was the part that changed the team's day: employees could see their own records instead of emailing HR, which removed a recurring queue of manual lookups.

- **Payroll run**: Records, payments, deductions, and advance salaries, reconciled against the same accounts as the rest of Finance.
- **Biometric attendance**: Device data integrated into attendance records so payroll inputs came from the source rather than a spreadsheet.
- **Employee management**: The employee record other modules read from, instead of each keeping its own copy.
- **Self-service portal**: Employees read their own payroll and attendance history without going through HR.

## One chain, seventy-eight kinds of approval

Approval engine

Every module had grown its own idea of approval. Journal entries had one system, advance salaries had their own columns, and each new process asked for a third. I replaced them with one chain. A model declares its approval type and adopts one trait, and the chain does the rest: who approves next, whether a step is mandatory or optional, how far along a record is, and who is told at each hand-off. By the time I left, 51 models used the trait.

Approval is not a status flip. It fires the real transaction. A payroll record approved generates its payments, posts the ledger entries and emails the payslips. A cheque book approved becomes active. An employee's staged data change is applied only on approval. Every one of those has a compensating path on rejection, and when a chain definition changes under records already in flight, a reconcile pass repairs them instead of leaving them stuck.

The engine also guards the data that sets prices. Charges, fire and marine rates, discounts, products, policy covers, banks and service providers carry an approval status and pass through a chain the business configures, so nobody changes a rate without a second person seeing it. Platform-wide the engine drives 78 approval types, and the lead engineer wired the insurance-side types onto it.

<!-- diagram:approval_chain -->
**Diagram:** One approval chain

*A model declares its type and adopts the trait. The service walks the chain, fires the real transaction on approval and the compensating path on rejection, and reconciles records already in flight when a chain is redefined.*

- *Retrofit* **Seventeen migrations**: Approval columns added to vendor bills, payments, purchase orders, bank reconciliations, budgets, journal entries, cheque books, fixed assets and the setup tables. The legacy journal-entry approval system was removed once the chain covered it.
- *Semantics* **Sequential and parallel**: Multi-level chains with a next-approver resolver, a mandatory-or-optional distinction per step, step N of M for the screen, and parallel approval where leave requests needed it.
- *Operations* **A console escape hatch**: An artisan command approves any model through the chain from the command line, for recovery when a person cannot. A dashboard widget lists what is waiting on whom.
- *The race* **Stuck after full approval**: A concurrency bug left records fully approved but never finalised. Nobody had filed it. I found it and fixed it in the chain.

The service behind the chain is about two thousand lines with one handler method per side effect, and I wrote about 60 percent of it. That is a shape I would not defend. The next refactor is one handler class per approval type behind a resolver, and I would rather say so here than have a reader find it.

## Rates in settings, not in code

Statutory payroll

The payroll rules arrived as a spreadsheet from the client's finance team: progressive income tax bands, a two-tier social security contribution, a housing levy, pension caps, and exemptions for interns. I built the engines so that none of those numbers live in code. Bands, limits and reliefs sit in payroll settings that HR can change, each with an effective date, so a historical payroll recomputes on the rates of its own period.

The tax walker uses inclusive band arithmetic that tracks the previous band's ceiling, which is the classic off-by-one in progressive tax code. It applies personal relief after the bands, floors the result at zero, and keeps a per-band breakdown for payslips and disputes. The contribution engine refuses to run when its tiers are not configured, with a message naming what is missing, rather than computing a wrong statutory deduction quietly. Both take a country code, because the insurer ran payroll in two East African countries with different statutory heads.

| Guard | What it stops |
| --- | --- |
| Pre-flight account check before approval | A payroll that would post to a ledger account that does not exist. The error names the missing account. |
| Accounting-period lock | Posting payroll into a closed or locked period. |
| Overdue remittance guard | Running the next payroll while statutory remittances are still outstanding. |
| Advance capped on net pay, not gross | A salary advance whose repayment could exceed what the employee takes home. |

Posting to the ledger is its own service. On an employee's first payroll it creates their sub-account under the salary control account, and does the same for each statutory payable. Every head gets its own debit and credit lines, employer and employee contributions are split, and a salary paid in another currency carries that currency into the entry. Mid-period salary changes are prorated across the change date, and working days come from work schedules, public holidays and per-employee overrides.

Two bugs from this module are worth telling. A partial repayment of a salary advance left the remaining balance stranded, so the next payroll would not deduct it. A polymorphic manager reference could point at the wrong model type for a matching id. Both reached production. Both are the kind of defect that mispays a real person quietly, and I fixed both.

> [!CAUTION]
> Rates, thresholds and the client's internal figures are deliberately absent. The engines are described by their shape, not their numbers.

## The boring guarantees, in one place

Middleware suite

Caching, rate limiting, security checks, and performance monitoring had been ad hoc: a check here, a cache call there, and no consistent story about what any endpoint promised. I designed and built the company's middleware suite from scratch so those guarantees became a stack an endpoint opts into.

The practical payoff was diagnostic: once every request went through the same monitoring path, slow endpoints stopped being anecdotes.

```php
Route::middleware([
    'api.security',     // header and payload checks
    'api.throttle',     // per-client rate limiting
    'api.cache',        // response caching, invalidated on write
    'api.monitor',      // timing and slow-query capture
])
```

*shape of the stack*

> [!CAUTION]
> Illustrative shape, not a verbatim copy of client code.

## Four things nobody had filed a ticket for

Security hardening

I led a hardening initiative after noticing the first issue while reading logs for an unrelated bug. None of these were assigned; all four were real.

- *Leaked secret* **Hardcoded API secret in server logs**: Removed the hardcoded value and moved it out of source, where it had been leaking into server logs on every call.
- *Log hygiene* **Sensitive values written to logs**: Stripped sensitive values out of log output so the same class of leak could not recur quietly somewhere else.
- *Stored XSS* **User HTML persisted without sanitization**: Closed the gap with allow-list HTML sanitization rather than a blocklist, so new tags fail closed instead of open.
- *SQL injection* **Raw string interpolation in queries**: Replaced interpolated SQL with parameterized queries across the affected paths.

## Alongside the lead engineer

Boundaries

Before Finance, I designed and built the original underwriting module from requirements the Directors had approved. As its scope grew past what one engineer should hold, I handed it to other developers on the team and moved to the domains above.

The quotations module, policies, endorsements, claims and reinsurance were the lead engineer's. I worked alongside them, reviewed and tested quotations and claims, and every one of those events posts into the ledger I built.

## Write the tests for the ledger

What I would do differently

Finance and payroll are exactly the code that should be covered by an automated suite from the first month, and for most of this engagement they were not. Verification leaned on careful manual checks with the accountants. The suite came in the tenth and eleventh months: 299 of the repository's 411 test files are mine, most of them written then. It works now, but the order was wrong, and the ledger ran for months on checks that did not protect the next developer.

I would also have built the middleware suite first rather than in the middle. Every module written before it had to be revisited to sit behind it.
