Modernizing an Insurance Platform
Overview
The FCI Claims Platform is a system used by FCI’s claims teams to process and pay out claims. It’s built on Laravel and Vue.js with Redis-backed WebSockets. The platform handles the full lifecycle of a claim, from intake to payment.
Over the past few years, I worked on this platform as a full-stack developer. I led several engineering initiatives that improved the system’s performance and functionality. I also worked closely with FCI’s client representatives to understand their needs and implement their ideas.
The three initiatives below share a common goal: they extend the same core real-time infrastructure to new parts of the business.
Business Challenge
Before these initiatives, the platform had several problems:
- Concurrent editing safety: Adjusters could overwrite each other’s changes without warning.
- Organizational depth: The platform only added or disabled dealership records, but didn’t represent dealer groups or territory managers.
- Controlled, auditable payment: Claims were paid out manually, with no system-enforced spending limits.
Engineering Initiatives
Real-Time Claim Ownership
Fixed a problem where two adjusters could edit the same claim at the same time.
Real-Time Claim Ownership
Fixed a problem where two adjusters could edit the same claim at the same time.
Problem
Adjusters worked from a shared claim queue, which meant they could open and edit the same claim at the same time. This caused problems:
- One adjuster’s changes would be lost if the other adjuster saved last.
- There was no way to see who else was working on a claim.
- Supervisors couldn’t intervene when a claim was “stuck.”
- As the number of claims grew, collisions became more frequent.
Solution
I built a system that assigns a claim to the first user who opens it and broadcasts that lock instantly to anyone else viewing the same claim.
When a second adjuster opens a locked claim, they see who has it and can review the claim without making changes. Supervisors with permission can reclaim a lock from a lower-permission user to unblock a stuck claim.
Technical Details
- Locks are checked against the claim’s live WebSocket presence channel.
- A scheduled cron job re-validates every locked claim against live channel occupancy.
- The system has a manual kill-switch to disable WebSockets in case of an outage.
- The system has a health check to verify the WebSocket service is reachable.
- If the health check fails, the system releases every lock platform-wide.
Engineering Considerations
The system originally used Pusher for WebSocket broadcasting. When Pusher experienced a service failure, the system was affected. To prevent this in the future, I made the following changes:
- Added a manual kill-switch to disable WebSockets in case of an outage.
- Added a health check to verify the WebSocket service is reachable.
- Added a scheduled cron job to release every lock platform-wide if the health check fails.
- Migrated the WebSocket layer from Pusher to Soketi, a self-hosted alternative.
Outcome
- Fixed the problem where two adjusters could edit the same claim at the same time.
- Gave supervisors a safe way to unblock stuck claims.
- Survived a live production outage of a core third-party dependency with no business disruption.
- Remained one of the most heavily used pieces of frontend logic on the platform for over 2 years.
Synchronizing Dealer Data and Hierarchy
Built an automated daily sync to keep dealer data in sync with a nested XML feed, with real-time cascading disablement.
Synchronizing Dealer Data and Hierarchy
Built an automated daily sync to keep dealer data in sync with a nested XML feed, with real-time cascading disablement.
Problem
Dealer data was being pulled from the FCI third-party API through daily XML feeds, but there was no existing automated process to keep the data in the app in sync with the feed. Dealer data had to be handled outside of any scheduled reconciliation.
Solution
Built an automated sync job that parses the XML feed and reconciles dealer records against it, including detecting new dealerships and disabling ones that dropped out.
The sync handles more than just a flat dealership record from day one, because the business had introduced a new layer of organizational structure sitting on top of dealerships:
- Dealer Groups — collections of dealerships under a shared parent.
- Territory Managers — users responsible for a set of dealerships.
- Territory Regions — a geographic/organizational grouping tying managers and dealerships together.
Every one of these new entities exists in relationship to a dealership, and dealerships themselves change constantly through the daily feed — added, disabled, reassigned.
Technical Highlights
- A purpose-built XML parser: Built a new
XmlUtilityparser to handle the complex XML structure, including nested and repeated tags. - Polymorphic relationship table: Used a single
user_relationshipstable to unify three otherwise-identical join tables, preventing duplicate relationship rows. - Reconciliation, not just insertion: Walked every dealer record in the parsed XML and called
updateOrCreatefor the dealer group, territory manager, and territory region tied to it, associating each back to the dealership as it goes. - Deactivation by absence: Tracked every dealer group/manager/region ID seen in this run and flipped
is_active = falseon anything in the database not in that set, triggering the cascade. - Pivot table rebuilt fresh every run: Territory manager-to-region assignments were collected during the loop, then cleared and re-synced from scratch every run.
- Cascade logic centralized: Centralized the cascade logic in the
DealerInfoService, making it easier to maintain and update.
Outcome
- Built an automated dealership sync where none existed before.
- Turned dealership data management into a full reconciliation of the dealership hierarchy.
- Eliminated stale org-structure data.
- Closed the real-time gap between a backend data change and its consequences for active users.
- Gave admins accurate, live views of dealerships and org structure via a targeted, route-aware refresh.
Virtual Credit Card (VCC) Payments via USBank
We built a payment pipeline that makes sure payments are safe and reliable, even when the third-party payment API is slow or unreliable.
Virtual Credit Card (VCC) Payments via USBank
We built a payment pipeline that makes sure payments are safe and reliable, even when the third-party payment API is slow or unreliable.
Problem
We had a problem where claims were paid instantly, without any checks on who was approving them or who was releasing the funds. This meant that a single person could approve and pay a claim all by themselves, without any checks.
The business wanted to let dealerships opt into being paid by virtual credit card instead, with USBank as the issuer. This introduced new requirements:
- Eligibility had to be checked before a card was ever requested. Not every user is authorized to pay every dollar amount, and a single user shouldn’t be able to both approve a claim and release its payment.
- A real financial instrument now had a lifecycle. A virtual card is created, has a balance, gets used, and eventually needs to be closed — none of which a flat “approved” status could represent on its own.
- The claim’s
active_statuscouldn’t just flip to “paid” the moment a user clicked approve. Card creation is a network call to a third party that can fail, time out, or need to be retried, so the status change had to be decoupled from the approval action and made resilient to USBank’s API being slow or unreliable.
What kicks off a payment. A user with edit access hits approveClaim() on an approved claim. If the dealership has VCC enabled ($dealership->vccEnabled()), the request runs through a chain of eligibility checks — checkTotal(), pay-point/pay-limit validation, platinumShieldApproval() — before anything is sent anywhere. Only once those pass does the claim either move straight to paid (non-VCC dealerships) or into payment_in_progress, and only then does CreditCardRemit get dispatched to actually talk to USBank.
What USBank’s API does. It’s a card-issuing API, not a full payment gateway — it doesn’t push money on its own or notify FCI when a card is used. UsBankClient authenticates with a client-credentials OAuth2 flow over mutual TLS (cert/ssl_key per environment), caches the resulting bearer token for 14 minutes (just under its 15-minute expiry), and then makes card operations against /virtual-cards/v1/cards: a POST with an Idempotency-Key to create a card and get back a card ID, CVV, and effective-until date; a /{id}/realtime-credit-details GET to pull a card’s live available balance; and a /{id}/close POST to close it out. Every request carries a fresh Correlation-ID for tracing a single call through USBank’s logs.
What had to be built around it. Because card creation is an external network call sitting inside a business process, almost everything interesting in this system exists to make that call safe to retry, safe to audit, and safe to leave half-finished without corrupting claim state.
Solution
We built the eligibility and dual-authorization logic into the approval endpoint itself, and everything downstream of “eligible” — card creation, status transition, notification, and eventual closure — into an idempotent, retryable job pipeline that reconciles with USBank rather than assuming a single request-response cycle will always succeed.
Eligibility and dual authorization live in approveClaim(). A user’s UserLimit (per program) determines both their pay_limit (max claim total they can release) and their pay_points (a weighted authority score). Two rules are enforced before a card is ever requested: the same user can’t be recorded as both the claim’s approver and its payer — checked by counting statusHistory() entries with a status of approved or pending_invoice against Auth::id() — and a single user’s pay_points must meet or exceed the claim’s ProgramLimit threshold, or the claim is parked in payment_in_progress so a second, different user’s points can combine with the first’s to clear it.
Card creation and status change happen inside CreditCardRemit, not the request/response cycle. The job builds the USBank payload from the claim’s XML-formatted data, requests the card with a cached idempotency key, and only updates virtual_card_id, is_virtual_card_open, vcc_effective_until, and vcc_balance once USBank confirms creation. If a card already exists for the claim (a retry after a partial failure), it skips straight to the status transition instead of creating a second card for the same claim.
Retry and failure handling assume USBank itself is unreliable. A ServerException on a 500 triggers a re-dispatch of the same job five minutes later, up to five times, per USBank’s own documented retry guidance — reusing the same idempotency key so a retried request can’t accidentally create a duplicate card. After five failures, or on any client-side error, failed() resets the claim’s in-progress flag and emails a dedicated USBank failure inbox so a stuck claim doesn’t sit silently.
Closure is a separate, independently reconcilable lifecycle step. Cards don’t close themselves just because a claim reached paid — CreditCardClose handles that as its own job with its own retry/backoff logic, and claims:close-credit-cards can run in bulk (auditing every open card’s live balance against USBank) or target a single claim by ID for manual intervention.
Technical Highlights
- Idempotency key reuse across retries.
generateIdempotencyKey()checks the cache for an existing key tied to the claim before generating a new one, and only clears it locally when testing — meaning a retried card-creation request after a transient failure reuses the exact same key USBank already saw, which is what prevents a retry from minting a second card for the same claim. - A dedicated logging channel instead of the default log.
UsBankLogis a facade that walks the exception backtrace to capture the calling line, then writes throughLog::channel('usbanklog')with a customLineFormatter(LineFormatter) rather than the default one. - Duplicate frontend authorization logic, deliberately.
canPay()in bothClaimSearchPageandClaimReviewHeaderindependently re-derives the same rule as the backend: not the same user who last approved, and the combinedpay_pointsof the current user and the claim’sactive_status.usermust meet the program’s required threshold. It’s re-implemented rather than shared because the two contexts (a full table row vs. a single claim’s action button) needed the check available synchronously in slightly different component shapes — the backend check inapproveClaim()remains the actual source of truth either way.
Engineering Considerations
The dual-approval, combined-pay-points rule was the hardest part of this system to get right, not because any single check was complicated, but because “approver and payer must differ” and “one person’s authority might not be enough, but two people’s combined might be” needed to be true consistently across three different surfaces: the approval endpoint itself, the claims table’s row-level canPay(), and the individual claim view’s canPay(). Each of those had access to slightly different data shapes — the table has a flat claim listing, the review header has the fully loaded active claim with its nested active_status.user — so the same business rule had to be re-derived against each shape correctly, rather than written once and trusted everywhere.
The failure mode that made this worth getting right: without the approver/payer check, a single user with sufficient combined authority across two logins or sessions could approve and pay a claim entirely alone, defeating the reason payment_in_progress existed in the first place. Without the combined-points check reading the correct other user ($claim->activeStatus->user, not just whoever’s currently authenticated), a claim could get silently stuck in payment_in_progress forever, because the math would never find a second party’s points to add — or worse, under-verify and let a claim clear with less combined authority than the program required. Getting the identity of “the other approver” right — pulled from the claim’s own status history rather than assumed from context — was what made the combined-limit check trustworthy enough to gate an actual financial instrument being created.
Outcome
- Gave FCI a payment path with real, enforced financial controls — spending limits per user, and a structural guarantee that approval and payment authority can’t collapse into a single person acting alone.
- Turned “approve a claim” from an instant status flip into a resilient pipeline that survives USBank being slow, erroring, or timing out, without ever risking a duplicate card for the same claim.
- Closed the gap USBank’s own webhooks left open with manual and automated reconciliation tools (
claims:close-credit-cards,ResetClaimUsBankRequestInProgress) rather than depending on a third party’s callback reliability. - Extended the platform’s existing real-time infrastructure — the same WebSocket health check and the same “reconcile on a schedule, don’t trust a single event” pattern from claim locking — into a payments context instead of building parallel infrastructure from scratch.
- Gave admins and dealership users live, accurate visibility into card and claim status without disrupting their place in a paginated, sorted table.
Claims Dashboard & Response-Time Analytics
Built the claims dashboard from scratch — role-aware activity feeds, pending-task
breakdowns, and two analytics charts — then rebuilt the underlying time
calculation after a business-hours miscalculation surfaced in production.
Claims Dashboard & Response-Time Analytics
Built the claims dashboard from scratch — role-aware activity feeds, pending-task breakdowns, and two analytics charts — then rebuilt the underlying time calculation after a business-hours miscalculation surfaced in production.
Problem
FCI’s claims teams had no single view into what needed attention or how the team was performing. Adjusters and admins had to work claim-by-claim through search filters to understand what was pending, and there was no visibility into how quickly claims were actually moving through the process.
The dashboard needed to serve two different audiences with two different permission levels — claims administrators and dealership-level users — without duplicating the page for each:
- Activity and task visibility had to reflect only the statuses and actions each role was allowed to act on, not a generic list filtered after the fact.
- Performance metrics — how long claims sat before a decision, and how long they took to actually process once submitted for payment — had no existing definition at all. What counted as the start and end of “response time” or “processing time” hadn’t been formally defined before this was built.
Solution
I built the dashboard as four independent panels — recent claim activity, a pending-tasks breakdown, two response/processing time charts, and a claim status summary — each backed by its own Vuex module state and API endpoint, so any panel can reload or filter independently of the others.
Role-aware pending tasks. DashboardModuleLoader::pendingTasks() builds an
entirely different task taxonomy depending on whether the user can edit claims.
An admin sees tasks grouped around approval authority — a single “Approvals”
bucket absorbing both Pending Approval and Re-Submitted claims, with
Re-Submitted also feeding a separate “Invoice Review” bucket filtered to
claims with an attached invoice. A dealership user instead sees their own
claim’s progress — information requests, invoice requests, awaiting payment —
with no approval-authority tasks at all, since those aren’t actionable for
their role. Each bucket’s count comes from a shared count_claims() closure
that re-applies the same status and attachment filters used to build the
bucket in the first place, rather than a second, independently-written query.
Two charts, two different definitions of “time.” DashboardService
computes average response time (claim submission → approval/decline, in
minutes) and average processing time (submitted for payment → paid, in days)
per claim type — tire & rim, key & remote, platinum shield, auto guard — off
the same underlying status-history walk, differing only in which status pairs
count and whether the result is measured in minutes or days.
Technical Highlights
- A single algorithm walking status-history pairs, parameterized by
direction.
findAvgTimeBetweenStatuses()walks each claim’s ordered status history looking for a transition from a defined “previous” status into a defined “active” status. Which status pairs qualify — and whether the result is measured in business minutes or calendar days — is entirely driven by theRespondedStatuses/ProcessedStatusesconstant passed in, so the same method backs both charts instead of two parallel implementations. - A rolling average computed inside the loop, not after it. Rather than
summing every status-change gap and dividing once at the end,
$total /= $status_change_countrecalculates the running average after every qualifying transition — which matters because a single claim can have more than one previous→active transition in its history (e.g., re-submitted and re-approved), and each one needed to be folded into that claim’s average as it was found. - Status IDs cached, not requeried per calculation.
getCachedStatusIds()caches the resolved status-ID lists for both chart types for 24 hours, since they’re looked up on every dashboard load and every filter change but change essentially never. - A debug export command for verifying the math independently of the UI.
dashboard:average-response-timeauthenticates as a super-admin, runs the sameDashboardServicelogic in a “testing” mode that returns full claim-level status data — not just the averages — then writes it to a temporary file behind a signed URL that expires in five minutes and deletes itself after download. Built specifically so the underlying claim-level data behind a suspicious average could be pulled and checked by date range, without needing direct database access. - Claim summary counts collapsed for non-admin views.
claimSummary()foldspayment_in_progresscounts intosubmitted_paymentand hides thepayment_in_progressrow entirely for non-FCIC-admin users — a payment sub-status that’s meaningful internally but not something a dealership user needs to distinguish.
Engineering Considerations
Roughly a year and a half after this shipped, following a permissions and role restructure elsewhere in the platform, a client-side executive flagged that the average response time on the dashboard looked wrong — the reported number was higher than it should reasonably be. Diagnosing it took real back-and-forth: the original ticket’s definition of “response time” wasn’t fully unambiguous, and confirming what the client actually wanted to measure required working through a client-side representative transition mid-thread, since the original contact who’d have known the original intent had since moved on.
The root cause turned out to be that the calculation counted raw wall-clock
time between status changes, including nights, weekends, and any gap where a
claim simply sat untouched outside business hours. I rebuilt the calculation
to count only business hours — 6AM to 6PM, excluding weekends — via
calculateBusinessMinutes(), which walks day-by-day between the two
timestamps, skips weekend days entirely, and clips each day’s contribution to
the 6AM–6PM window (including partial first and last days). That single
change dropped the reported average significantly and brought it in line with
what the business actually expected the number to represent.
Verifying the fix wasn’t a one-shot deploy — it went through a UAT round checking specific claims against specific dates, including edge cases like claims created or actioned outside official hours, before it was signed off as correct.
Outcome
- Gave claims teams and dealership users a single dashboard reflecting only the actions and data relevant to their own permission level, instead of a generic view filtered after the fact.
- Defined and shipped the platform’s first formal response-time and processing-time metrics, broken out per claim type.
- Diagnosed and corrected a real production data-accuracy issue flagged by the client, rebuilding the core time calculation around actual business hours rather than wall-clock time — verified through a full UAT pass before sign-off.
- Built lasting debug tooling (
dashboard:average-response-time) that lets the underlying claim-level data behind any reported average be pulled and audited independently of the dashboard UI.
Architecture
The platform is built on a Laravel backend and a Vue 2 frontend, with MySQL for data storage and Redis-backed WebSockets driving real-time updates.
A common pattern across the major initiatives is to verify state independently, rather than relying on a single event to be correct. This is achieved through WebSocket health checks and scheduled reconciliation jobs.
Results
The initiatives achieved several goals:
- Concurrent editing safety: A locking system was implemented to prevent silent overwrites.
- Survived a production outage: The system survived a live production outage of a core third-party dependency without business disruption.
- Full reconciliation of dealership hierarchy: The platform now syncs the dealership organizational hierarchy in real-time.
- Virtual credit card payment path: The platform now has a virtual credit card payment path with enforced financial controls.
- Reliability gap in webhooks: The platform now closes a reliability gap in USBank’s webhooks with automated and manual reconciliation tooling.
Client Collaboration
I worked directly with FCI’s client representatives as the day-to-day technical point of contact. I scoped what was feasible, walked through trade-offs, and brought my own ideas to the table. This relationship spanned roughly three years and two different representatives.
Scope
Backend
- Real-time claim ownership and locking
- WebSocket infrastructure (Pusher → Soketi migration)
- Scheduled reconciliation jobs (locking, org sync, payments)
- XML data sync and dealership hierarchy modeling
- Virtual credit card payment pipeline (USBank integration)
- Dual-authorization approval logic
Frontend
- Real-time lock state via Vuex-backed mixins
- Live claim, dealership, and org-structure table updates
- Row-level and claim-level payment authorization checks
Infrastructure
- MySQL, Redis
- Self-hosted Soketi WebSocket server
- USBank card-issuing API (OAuth2 client credentials, mutual TLS)




