case study13 min read

Building a Compliance-First HR & Payroll Platform: Rule-Based Automation, LLM-Grounded Q&A, and a Provider-Neutral Billing Core

By Sourav Dutt
LaravelPHPMySQLAILLMPayrollComplianceRazorpaySaaS

Building a Compliance-First HR & Payroll Platform

Project Overview

HRM is a payroll and compliance SaaS for Indian SMEs, built as founder and sole engineer at TechGeeta — architecture, backend, frontend, DevOps, and product, end to end. Payroll in India is unusually rule-heavy: EPF, ESI, Professional Tax, and gender-based statutory reporting all have to be calculated correctly, not approximately. That constraint shaped most of the interesting engineering in this codebase, along with two AI subsystems that solve genuinely different problems and are built in genuinely different ways.

Stack: Laravel 12, PHP 8.4, MySQL, Alpine.js, Tailwind v4, Pest, Sanctum Scale: ~58K lines of application code, 53 models, 49 controllers, 120+ Pest test files Status: Shipped to early paying customers

Two AI Subsystems, Deliberately Built Differently

Most "AI-powered" pitches conflate everything behind a single LLM call. This platform has two AI-adjacent subsystems, and they're not interchangeable — one is a deterministic parser because the failure mode of a wrong payroll write is unacceptable; the other is a real LLM call because the failure mode of an unhelpful compliance answer is just a bad user experience.

1. The AI Action Layer — deterministic, not an LLM call

The action layer lets an admin type something like "give Harpreet Singh a 10% raise" and have it safely executed against real payroll data. It's intentionally not an LLM call at the parsing stage — a keyword-scored intent classifier decides what the admin is asking for, so behavior is deterministic, auditable, and doesn't depend on a model provider staying up or a prompt not drifting.

class IntentParser
{
    private const CONFIDENCE_THRESHOLD = 4;
    private const AMBIGUITY_GAP = 2; // top-two-score gap that triggers a clarify question

    private const KEYWORD_MAP = [
        'ChangeSalary' => [
            'change' => 3, 'increase' => 3, 'raise' => 3, 'hike' => 3,
            'salary' => 2, 'basic' => 2, 'ctc' => 2,
            // ... typo-tolerant variants scored lower
        ],
        // ...one weighted map per intent
    ];

    public function parse(string $input): ActionPlan
    {
        $scores = $this->scoreAllIntents($input);
        [$top, $second] = $this->topTwo($scores);

        if ($top->score < self::CONFIDENCE_THRESHOLD) {
            return ActionPlan::unknown();
        }
        if ($top->score - $second->score <= self::AMBIGUITY_GAP) {
            return ActionPlan::clarify($top, $second);
        }
        return ActionPlan::forIntent($top->intent, $input);
    }
}

Every plan then goes through a strict parse → confirm → execute separation: parsing is provably side-effect-free, and nothing writes to payroll data without an explicit confirmation step, re-checked permissions, and a full audit log entry (AiActionLog). If "Harpreet Singh" matches two employees — a real occurrence in Indian SME data, where rejoiners and same-name-different-department collisions happen — the system halts and asks rather than guessing. Domain-aware extractors handle Indian numeric formats (₹10,000, 10k, 1 lakh), resolve relative dates against the Indian financial year (April–March), and flag ESI/PT slab-boundary crossings triggered by a salary change.

2. The Compliance Assistant — genuinely LLM-backed, grounded, and rate-limited

Separately, a compliance Q&A assistant answers free-form questions about EPF, ESI, bonus, gratuity, and the Labour Welfare Fund. This one is a real LLM call — provider-agnostic (OpenAI or Anthropic, swappable via config), with the model resolved per subscription plan tier.

class ComplianceAnswerService
{
    // Pipeline: detect domain → build grounded context from DB →
    // call LLM with system prompt + history → return answer|clarification
    public function answer(string $query, int $companyId, int $billingAccountId): array
    {
        if (! $this->rateLimiter->for($billingAccountId)->check()['allowed']) {
            return $this->rateLimitedResponse();
        }

        $domain = $this->domainDetector->detect($query); // epf|esi|bonus|gratuity|lwf|null
        $context = $this->contextBuilder->build($companyId, $domain);

        return Prism::text()
            ->using($this->provider, $this->model)
            ->withSystemPrompt($this->promptEngine->build($context))
            ->withMessages([new UserMessage($query)])
            ->generate();
    }
}

The domain detector is deliberately dumb on purpose — it's a keyword router, not the language understanding: "the LLM handles all language understanding, this just narrows which DB slice to fetch." That distinction keeps the grounding context small and relevant instead of dumping the whole compliance schema into every prompt. Cost and abuse are bounded per billing account with daily and monthly call caps, configurable per-account or platform-wide, checked before every LLM call.

A Provider-Neutral Billing Core

Most side projects integrate one payment provider and stop. This one is architected so a second and third gateway can be added without touching a controller. BillingGatewayResolver and BillingOrchestrator sit between the app and any provider — checkout, confirmation, and webhook handling are all routed through a neutral seam instead of being called directly.

Razorpay is live in production. PayU and Cashfree are config-registered but fail closed: any attempt to start a checkout against them throws BillingGatewayNotReadyException until their webhook, invoicing, and retry paths are actually implemented. That's a deliberate guardrail against a half-finished payment path shipping by accident, not an oversight.

The migration itself is happening live, without a big-bang rewrite: legacy razorpay_* columns are being phased out in favor of additive, gateway-agnostic mapping tables (billing_gateway_customers, billing_gateway_plans, billing_gateway_subscriptions) that a second gateway can populate without touching the first.

Dynamic Salary Templates, Snapshotted for Reproducibility

Companies can define custom salary columns per organization. Which columns apply to a given pay run is resolved through a priority chain: existing pay-run snapshot → company template → billing-account template → system defaults. That ordering matters — a template edit made today must never silently rewrite payroll that was already calculated last month.

The resolved column set is snapshotted onto each SalaryHead at run time (column_snapshot), so historical payslips stay reproducible even after a company changes its template later. Hidden-column semantics — a column defined but not shown to the employee — are enforced identically across PHP calculation, Blade rendering, and the JS live-preview, so there's exactly one source of truth for "is this column visible," not three that can drift out of sync.

What I'd Highlight in a Technical Interview

If I only got to go deep on one thing from this codebase, it'd be the parse-confirm-execute split in the AI Action Layer — it's a concrete answer to "how do you make an AI feature safe in a system that touches money," not a hypothetical. The second choice would be the billing core's fail-closed gateway registration, because "how do you add a payment provider without rewriting checkout" is a question with real code behind it here, not a whiteboard sketch.

Tech Stack: Laravel 12, PHP 8.4, MySQL, Alpine.js, Tailwind v4, Pest, Sanctum, Razorpay, Prism (OpenAI/Anthropic) Role: Founder & Sole Engineer, TechGeeta Duration: Oct 2023 - Present Status: Shipped to early paying customers

About Sourav Dutt

Senior Product Engineer with 6+ years building AI-powered SaaS platforms for US startups. Expert in React, Next.js, Node.js, Laravel, and LLM integrations.